towow-progress 0.1.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.
@@ -0,0 +1,117 @@
1
+ # OS files
2
+ .DS_Store
3
+ *.DS_Store
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *$py.class
9
+ *.so
10
+ .Python
11
+ *.egg
12
+ *.egg-info/
13
+ venv/
14
+ .venv/
15
+ *.db
16
+ .pytest_cache/
17
+ .coverage
18
+ htmlcov/
19
+
20
+ # Node.js
21
+ node_modules/
22
+
23
+ # Remotion animation project (too large)
24
+ towow-animation/
25
+
26
+ # Playwright MCP cache
27
+ .playwright-mcp/
28
+
29
+ # Worktrees (local development)
30
+ worktree-*/
31
+ .dev/worktree/
32
+
33
+ # Logs
34
+ *.log
35
+
36
+ # IDE
37
+ .idea/
38
+ .vscode/
39
+
40
+ # Environment files
41
+ .env
42
+ .env.local
43
+
44
+ # ML model weights (local only, too large for git)
45
+ backend/models/
46
+
47
+ # Data directories
48
+ data/
49
+ # Allow pre-computed vectors (production needs this, no ML framework required)
50
+ !data/agent_vectors.npz
51
+ # Allow AToA app agent data (config, not runtime)
52
+ !apps/*/data/
53
+ apps/*/data/*
54
+ !apps/*/data/agents.json
55
+
56
+ # Message history
57
+ mods/openagents.mods.workspace.messaging/
58
+
59
+ # Build outputs
60
+ dist/
61
+ build/
62
+ out/
63
+ .next/
64
+
65
+ # Archives
66
+ *.zip
67
+
68
+ # Prompts (proprietary — core IP, do not publish)
69
+ tests/crystallization_poc/prompts/
70
+ tests/crystallization_poc/PLAN-prompt-experiment-recruit-v1.md
71
+ tests/crystallization_poc/simulations/real/assemble_prompts.py
72
+ scenes/*/prompts/
73
+ docs/prompts/
74
+
75
+ # Real user experiment data (NEVER upload — contains real people's profiles and outputs)
76
+ tests/crystallization_poc/state.json
77
+ tests/crystallization_poc/simulations/real/run_*/
78
+
79
+ # Wrangler local cache (contains account credentials)
80
+ .wrangler/
81
+
82
+ # Guard session signals (per-session runtime artifacts, ADR-030)
83
+ .towow/guard/
84
+
85
+ # Separate git repositories
86
+ openagents/
87
+ backend/cache/
88
+
89
+ # Screenshots & images (local reference only)
90
+ *.png
91
+ *.jpeg
92
+ *.jpg
93
+ # Allow specific tracked images if needed
94
+ !website/public/**/*.png
95
+ !website/public/**/*.jpg
96
+ !website/public/**/*.svg
97
+ !docs/decisions/tasks/*/screenshots/*.png
98
+
99
+ # Design prototypes (local only)
100
+ scenes/*/demo-app/prototypes/
101
+
102
+ # Vercel build output
103
+ .vercel/
104
+ .vercelignore
105
+
106
+ # Deploy secrets
107
+ deploy/.env*
108
+
109
+ # Root package files (generated by accident)
110
+ /package.json
111
+ /package-lock.json
112
+
113
+ # MCP server lock file (generated locally)
114
+ mcp-server/package-lock.json
115
+
116
+ # Claude worktrees
117
+ .claude/worktrees/
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: towow-progress
3
+ Version: 0.1.0
4
+ Summary: AI-native development progress dashboard — turn your git history into a beautiful narrative page.
5
+ Project-URL: Homepage, https://towow.net
6
+ Project-URL: Repository, https://github.com/NatureBlueee/Towow
7
+ Author-email: Towow <hello@towow.net>
8
+ License-Expression: MIT
9
+ Keywords: ai,dashboard,developer-tools,git,progress
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Build Tools
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+
18
+ # towow-progress
19
+
20
+ Turn your git history into a beautiful, artistic progress page. Not a boring dashboard — a narrative that tells your project's story.
21
+
22
+ ![MIT License](https://img.shields.io/badge/license-MIT-blue)
23
+
24
+ ## Quick Start
25
+
26
+ ```bash
27
+ pip install towow-progress
28
+ cd your-repo
29
+ towow-progress init # auto-detect modules, create config
30
+ towow-progress generate # build docs/progress.html
31
+ open docs/progress.html # done!
32
+ ```
33
+
34
+ ## Let Your AI Set It Up
35
+
36
+ Paste this to Claude, ChatGPT, or any coding AI:
37
+
38
+ > Install `towow-progress`, run `towow-progress init` on this repo, then customize `.towow-progress.json` — set a good title, subtitle, and adjust module names to be human-readable. Then run `towow-progress generate` and open the result.
39
+
40
+ Your AI will analyze your repo structure and configure everything.
41
+
42
+ ## What You Get
43
+
44
+ - **Hero section** — total commits, lines changed, active days, longest streak
45
+ - **Live indicator** — latest commit with relative time ("3m ago")
46
+ - **Scrollable timeline** — daily commit activity with peak highlights
47
+ - **Module trends** — stacked area chart showing strategic focus shifts
48
+ - **Distribution** — horizontal bar chart of code by module
49
+ - **Weekly velocity** — line chart with acceleration/deceleration analysis
50
+ - **Changelog** — expandable recent commits with type badges, file counts, diffs
51
+
52
+ Single self-contained HTML file. Works offline. Deploy anywhere.
53
+
54
+ ## Configuration
55
+
56
+ `towow-progress init` creates `.towow-progress.json`:
57
+
58
+ ```json
59
+ {
60
+ "title": "My Project Progress",
61
+ "subtitle": "Built by a team of 3 over 120 days",
62
+ "lang": "en",
63
+ "modules": {
64
+ "src": "Core Engine",
65
+ "api": "API Layer",
66
+ "web": "Frontend",
67
+ "tests": "Test Suite",
68
+ "docs": "Documentation"
69
+ },
70
+ "colors": {
71
+ "src": "#1D4ED8",
72
+ "api": "#059669",
73
+ "web": "#EA580C",
74
+ "tests": "#7C3AED",
75
+ "docs": "#0891B2"
76
+ },
77
+ "output": "docs/progress.html",
78
+ "accent_color": "#1D4ED8",
79
+ "accent_secondary": "#EA580C",
80
+ "background": "#F7F4F0",
81
+ "font_family": "",
82
+ "branding": true
83
+ }
84
+ ```
85
+
86
+ ### What you can customize
87
+
88
+ | Field | What it does | Example |
89
+ |-------|-------------|---------|
90
+ | `title` | Big hero title | `"Acme Engine"` |
91
+ | `subtitle` | Line under the title | `"3 engineers · 200 days"` |
92
+ | `modules` | Map directory prefixes to display names | `{"src": "Core"}` |
93
+ | `colors` | Hex color per module | `{"src": "#1D4ED8"}` |
94
+ | `accent_color` | Primary brand color (borders, links, charts) | `"#8B5CF6"` (purple) |
95
+ | `accent_secondary` | Highlight color (peaks, badges) | `"#F59E0B"` (amber) |
96
+ | `background` | Page background | `"#FAFAF9"` (stone) |
97
+ | `font_family` | Custom font stack | `"'JetBrains Mono', monospace"` |
98
+ | `output` | Where to write the HTML | `"public/progress.html"` |
99
+ | `branding` | Show "Powered by Towow" footer | `true` / `false` |
100
+
101
+ ### Color presets
102
+
103
+ **Default (warm ivory + blue)**
104
+ ```json
105
+ { "accent_color": "#1D4ED8", "accent_secondary": "#EA580C", "background": "#F7F4F0" }
106
+ ```
107
+
108
+ **Dark academia**
109
+ ```json
110
+ { "accent_color": "#78350F", "accent_secondary": "#B45309", "background": "#FEFCE8" }
111
+ ```
112
+
113
+ **Cyberpunk**
114
+ ```json
115
+ { "accent_color": "#7C3AED", "accent_secondary": "#EC4899", "background": "#FAF5FF" }
116
+ ```
117
+
118
+ **Forest**
119
+ ```json
120
+ { "accent_color": "#065F46", "accent_secondary": "#D97706", "background": "#F0FDF4" }
121
+ ```
122
+
123
+ ## Auto-Update with GitHub Actions
124
+
125
+ ```bash
126
+ towow-progress setup-ci
127
+ ```
128
+
129
+ This creates `.github/workflows/towow-progress.yml`. On every push to `main`, it regenerates and commits the HTML.
130
+
131
+ **Deploy to GitHub Pages:**
132
+ 1. Run `towow-progress setup-ci`
133
+ 2. Go to repo Settings → Pages → Source: `Deploy from a branch` → Branch: `main`, folder: `/docs`
134
+ 3. Push. Your progress page is live at `https://username.github.io/repo/progress.html`
135
+
136
+ ## How It Works
137
+
138
+ 1. **Scans git history** — `git log --numstat` to get per-file additions/deletions
139
+ 2. **Classifies files** — maps file paths to modules using your config
140
+ 3. **Computes analytics** — daily/weekly/monthly aggregations, velocity, streaks
141
+ 4. **Renders HTML** — single file with inline CSS + Chart.js from CDN
142
+
143
+ No database. No server. No API keys. Just git + Python.
144
+
145
+ ---
146
+
147
+ Powered by [Towow](https://towow.net) — AI-native collaboration protocol
@@ -0,0 +1,130 @@
1
+ # towow-progress
2
+
3
+ Turn your git history into a beautiful, artistic progress page. Not a boring dashboard — a narrative that tells your project's story.
4
+
5
+ ![MIT License](https://img.shields.io/badge/license-MIT-blue)
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ pip install towow-progress
11
+ cd your-repo
12
+ towow-progress init # auto-detect modules, create config
13
+ towow-progress generate # build docs/progress.html
14
+ open docs/progress.html # done!
15
+ ```
16
+
17
+ ## Let Your AI Set It Up
18
+
19
+ Paste this to Claude, ChatGPT, or any coding AI:
20
+
21
+ > Install `towow-progress`, run `towow-progress init` on this repo, then customize `.towow-progress.json` — set a good title, subtitle, and adjust module names to be human-readable. Then run `towow-progress generate` and open the result.
22
+
23
+ Your AI will analyze your repo structure and configure everything.
24
+
25
+ ## What You Get
26
+
27
+ - **Hero section** — total commits, lines changed, active days, longest streak
28
+ - **Live indicator** — latest commit with relative time ("3m ago")
29
+ - **Scrollable timeline** — daily commit activity with peak highlights
30
+ - **Module trends** — stacked area chart showing strategic focus shifts
31
+ - **Distribution** — horizontal bar chart of code by module
32
+ - **Weekly velocity** — line chart with acceleration/deceleration analysis
33
+ - **Changelog** — expandable recent commits with type badges, file counts, diffs
34
+
35
+ Single self-contained HTML file. Works offline. Deploy anywhere.
36
+
37
+ ## Configuration
38
+
39
+ `towow-progress init` creates `.towow-progress.json`:
40
+
41
+ ```json
42
+ {
43
+ "title": "My Project Progress",
44
+ "subtitle": "Built by a team of 3 over 120 days",
45
+ "lang": "en",
46
+ "modules": {
47
+ "src": "Core Engine",
48
+ "api": "API Layer",
49
+ "web": "Frontend",
50
+ "tests": "Test Suite",
51
+ "docs": "Documentation"
52
+ },
53
+ "colors": {
54
+ "src": "#1D4ED8",
55
+ "api": "#059669",
56
+ "web": "#EA580C",
57
+ "tests": "#7C3AED",
58
+ "docs": "#0891B2"
59
+ },
60
+ "output": "docs/progress.html",
61
+ "accent_color": "#1D4ED8",
62
+ "accent_secondary": "#EA580C",
63
+ "background": "#F7F4F0",
64
+ "font_family": "",
65
+ "branding": true
66
+ }
67
+ ```
68
+
69
+ ### What you can customize
70
+
71
+ | Field | What it does | Example |
72
+ |-------|-------------|---------|
73
+ | `title` | Big hero title | `"Acme Engine"` |
74
+ | `subtitle` | Line under the title | `"3 engineers · 200 days"` |
75
+ | `modules` | Map directory prefixes to display names | `{"src": "Core"}` |
76
+ | `colors` | Hex color per module | `{"src": "#1D4ED8"}` |
77
+ | `accent_color` | Primary brand color (borders, links, charts) | `"#8B5CF6"` (purple) |
78
+ | `accent_secondary` | Highlight color (peaks, badges) | `"#F59E0B"` (amber) |
79
+ | `background` | Page background | `"#FAFAF9"` (stone) |
80
+ | `font_family` | Custom font stack | `"'JetBrains Mono', monospace"` |
81
+ | `output` | Where to write the HTML | `"public/progress.html"` |
82
+ | `branding` | Show "Powered by Towow" footer | `true` / `false` |
83
+
84
+ ### Color presets
85
+
86
+ **Default (warm ivory + blue)**
87
+ ```json
88
+ { "accent_color": "#1D4ED8", "accent_secondary": "#EA580C", "background": "#F7F4F0" }
89
+ ```
90
+
91
+ **Dark academia**
92
+ ```json
93
+ { "accent_color": "#78350F", "accent_secondary": "#B45309", "background": "#FEFCE8" }
94
+ ```
95
+
96
+ **Cyberpunk**
97
+ ```json
98
+ { "accent_color": "#7C3AED", "accent_secondary": "#EC4899", "background": "#FAF5FF" }
99
+ ```
100
+
101
+ **Forest**
102
+ ```json
103
+ { "accent_color": "#065F46", "accent_secondary": "#D97706", "background": "#F0FDF4" }
104
+ ```
105
+
106
+ ## Auto-Update with GitHub Actions
107
+
108
+ ```bash
109
+ towow-progress setup-ci
110
+ ```
111
+
112
+ This creates `.github/workflows/towow-progress.yml`. On every push to `main`, it regenerates and commits the HTML.
113
+
114
+ **Deploy to GitHub Pages:**
115
+ 1. Run `towow-progress setup-ci`
116
+ 2. Go to repo Settings → Pages → Source: `Deploy from a branch` → Branch: `main`, folder: `/docs`
117
+ 3. Push. Your progress page is live at `https://username.github.io/repo/progress.html`
118
+
119
+ ## How It Works
120
+
121
+ 1. **Scans git history** — `git log --numstat` to get per-file additions/deletions
122
+ 2. **Classifies files** — maps file paths to modules using your config
123
+ 3. **Computes analytics** — daily/weekly/monthly aggregations, velocity, streaks
124
+ 4. **Renders HTML** — single file with inline CSS + Chart.js from CDN
125
+
126
+ No database. No server. No API keys. Just git + Python.
127
+
128
+ ---
129
+
130
+ Powered by [Towow](https://towow.net) — AI-native collaboration protocol
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "towow-progress"
7
+ version = "0.1.0"
8
+ description = "AI-native development progress dashboard — turn your git history into a beautiful narrative page."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Towow", email = "hello@towow.net" }]
13
+ keywords = ["git", "dashboard", "progress", "ai", "developer-tools"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Topic :: Software Development :: Build Tools",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ ]
21
+
22
+ [project.scripts]
23
+ towow-progress = "towow_progress.cli:main"
24
+
25
+ [project.urls]
26
+ Homepage = "https://towow.net"
27
+ Repository = "https://github.com/NatureBlueee/Towow"
@@ -0,0 +1,2 @@
1
+ """Towow Progress — AI-native development progress dashboard."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,128 @@
1
+ """CLI entry point for towow-progress."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(
12
+ prog="towow-progress",
13
+ description="AI-native development progress dashboard",
14
+ )
15
+ sub = parser.add_subparsers(dest="command")
16
+
17
+ # init
18
+ init_p = sub.add_parser("init", help="Initialize config for your repo")
19
+ init_p.add_argument("--repo", type=Path, default=Path("."))
20
+ init_p.add_argument("--invite", type=str, help="Towow invite code")
21
+
22
+ # generate
23
+ gen_p = sub.add_parser("generate", help="Generate the progress page")
24
+ gen_p.add_argument("--repo", type=Path, default=Path("."))
25
+ gen_p.add_argument("--output", type=Path, default=None)
26
+
27
+ # setup-ci
28
+ ci_p = sub.add_parser("setup-ci", help="Create GitHub Actions workflow")
29
+ ci_p.add_argument("--repo", type=Path, default=Path("."))
30
+
31
+ args = parser.parse_args()
32
+
33
+ if args.command == "init":
34
+ _cmd_init(args)
35
+ elif args.command == "generate":
36
+ _cmd_generate(args)
37
+ elif args.command == "setup-ci":
38
+ _cmd_setup_ci(args)
39
+ else:
40
+ parser.print_help()
41
+
42
+
43
+ def _cmd_init(args):
44
+ from .config import ProgressConfig
45
+ from .detect import detect_modules
46
+
47
+ repo = args.repo.resolve()
48
+ print(f"Scanning {repo} ...")
49
+
50
+ detected = detect_modules(repo)
51
+ config = ProgressConfig(
52
+ modules=detected["modules"],
53
+ colors=detected["colors"],
54
+ )
55
+
56
+ cf = config.save(repo)
57
+ print(f"\n✦ Detected {len(config.modules)} modules:")
58
+ for k, v in config.modules.items():
59
+ print(f" {k}/ → {v}")
60
+ print(f"\nConfig: {cf}")
61
+ print(f"\n── AI Agent: please customize the config ──")
62
+ print(f"1. Read {cf} and improve: set a compelling title/subtitle,")
63
+ print(f" rename modules to human-readable names, pick brand colors.")
64
+ print(f" Available fields: title, subtitle, modules, colors,")
65
+ print(f" accent_color, accent_secondary, background, font_family.")
66
+ print(f"2. Run: towow-progress generate")
67
+ print(f"3. Optionally run: towow-progress setup-ci (GitHub Actions auto-update)")
68
+
69
+
70
+ def _cmd_generate(args):
71
+ from .config import ProgressConfig
72
+ from .generator import build_report
73
+
74
+ repo = args.repo.resolve()
75
+ config = ProgressConfig.load(repo)
76
+ output = args.output or Path(config.output)
77
+
78
+ build_report(repo, output, config)
79
+
80
+
81
+ def _cmd_setup_ci(args):
82
+ repo = args.repo.resolve()
83
+ wf_dir = repo / ".github" / "workflows"
84
+ wf_dir.mkdir(parents=True, exist_ok=True)
85
+ wf_file = wf_dir / "towow-progress.yml"
86
+
87
+ wf_file.write_text("""name: Update Progress Dashboard
88
+
89
+ on:
90
+ push:
91
+ branches: [main]
92
+ workflow_dispatch:
93
+
94
+ permissions:
95
+ contents: write
96
+
97
+ jobs:
98
+ update:
99
+ runs-on: ubuntu-latest
100
+ steps:
101
+ - uses: actions/checkout@v4
102
+ with:
103
+ fetch-depth: 0
104
+
105
+ - uses: actions/setup-python@v5
106
+ with:
107
+ python-version: "3.12"
108
+
109
+ - name: Install towow-progress
110
+ run: pip install towow-progress
111
+
112
+ - name: Generate dashboard
113
+ run: towow-progress generate
114
+
115
+ - name: Commit if changed
116
+ run: |
117
+ git config user.name "github-actions[bot]"
118
+ git config user.email "github-actions[bot]@users.noreply.github.com"
119
+ git add -A docs/progress.html
120
+ git diff --cached --quiet || git commit -m "chore: update progress dashboard"
121
+ git push
122
+ """)
123
+ print(f"Created {wf_file}")
124
+ print("Dashboard will auto-update on every push to main.")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
@@ -0,0 +1,51 @@
1
+ """Configuration for towow-progress."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ CONFIG_FILE = ".towow-progress.json"
10
+
11
+
12
+ @dataclass
13
+ class ProgressConfig:
14
+ title: str = "Development Progress"
15
+ subtitle: str = ""
16
+ lang: str = "en"
17
+ modules: dict[str, str] = field(default_factory=dict)
18
+ colors: dict[str, str] = field(default_factory=dict)
19
+ output: str = "docs/progress.html"
20
+ branding: bool = True
21
+ # Customization
22
+ accent_color: str = "#1D4ED8" # Primary brand color (hero border, links)
23
+ accent_secondary: str = "#EA580C" # Secondary accent (highlights, peaks)
24
+ background: str = "#F7F4F0" # Page background
25
+ font_family: str = "" # Custom font (empty = Inter default)
26
+
27
+ @classmethod
28
+ def load(cls, repo: Path) -> "ProgressConfig":
29
+ cf = repo / CONFIG_FILE
30
+ if cf.exists():
31
+ data = json.loads(cf.read_text())
32
+ return cls(**{k: v for k, v in data.items()
33
+ if k in cls.__dataclass_fields__})
34
+ return cls()
35
+
36
+ def save(self, repo: Path) -> Path:
37
+ cf = repo / CONFIG_FILE
38
+ cf.write_text(json.dumps({
39
+ "title": self.title,
40
+ "subtitle": self.subtitle,
41
+ "lang": self.lang,
42
+ "modules": self.modules,
43
+ "colors": self.colors,
44
+ "output": self.output,
45
+ "branding": self.branding,
46
+ "accent_color": self.accent_color,
47
+ "accent_secondary": self.accent_secondary,
48
+ "background": self.background,
49
+ "font_family": self.font_family,
50
+ }, indent=2, ensure_ascii=False))
51
+ return cf
@@ -0,0 +1,119 @@
1
+ """Auto-detect repository structure and generate module mappings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from collections import Counter
7
+ from pathlib import Path
8
+
9
+ # Common directory patterns → module names
10
+ _KNOWN_PATTERNS = {
11
+ "src": "Source",
12
+ "lib": "Library",
13
+ "app": "Application",
14
+ "api": "API",
15
+ "server": "Server",
16
+ "backend": "Backend",
17
+ "frontend": "Frontend",
18
+ "web": "Web",
19
+ "client": "Client",
20
+ "mobile": "Mobile",
21
+ "ios": "iOS",
22
+ "android": "Android",
23
+ "docs": "Documentation",
24
+ "doc": "Documentation",
25
+ "test": "Tests",
26
+ "tests": "Tests",
27
+ "spec": "Tests",
28
+ "__tests__": "Tests",
29
+ "e2e": "E2E Tests",
30
+ "scripts": "Scripts",
31
+ "tools": "Tools",
32
+ "config": "Config",
33
+ "infra": "Infrastructure",
34
+ "deploy": "Deploy",
35
+ "ci": "CI/CD",
36
+ ".github": "CI/CD",
37
+ "packages": "Packages",
38
+ "modules": "Modules",
39
+ "components": "Components",
40
+ "services": "Services",
41
+ "models": "Models",
42
+ "utils": "Utilities",
43
+ "assets": "Assets",
44
+ "public": "Public",
45
+ "static": "Static",
46
+ "data": "Data",
47
+ "migrations": "Migrations",
48
+ "prisma": "Database",
49
+ "db": "Database",
50
+ }
51
+
52
+ # Color palette for modules
53
+ _PALETTE = [
54
+ "#1D4ED8", "#2563EB", "#EA580C", "#059669", "#DC2626",
55
+ "#0891B2", "#7C3AED", "#0D9488", "#D97706", "#DB2777",
56
+ "#64748B", "#475569", "#9333EA", "#0284C7", "#B91C1C",
57
+ ]
58
+
59
+
60
+ def detect_modules(repo: Path) -> dict:
61
+ """Scan the repo's git history to find top-level directories and classify them.
62
+
63
+ Returns a config dict with:
64
+ - modules: {dir_prefix: display_name}
65
+ - colors: {dir_prefix: hex_color}
66
+ """
67
+ # Get all files ever tracked by git
68
+ try:
69
+ raw = subprocess.run(
70
+ ["git", "-C", str(repo), "log", "--all", "--name-only",
71
+ "--format=", "--diff-filter=ACMR"],
72
+ capture_output=True, text=True, check=True,
73
+ ).stdout
74
+ except subprocess.CalledProcessError:
75
+ return _fallback_detect(repo)
76
+
77
+ # Count top-level directories
78
+ dir_counts: Counter = Counter()
79
+ for line in raw.splitlines():
80
+ line = line.strip()
81
+ if not line or "/" not in line:
82
+ continue
83
+ top = line.split("/", 1)[0]
84
+ if top.startswith(".") and top not in (".github",):
85
+ continue
86
+ if not top.isascii() or not all(c.isalnum() or c in "-_." for c in top):
87
+ continue
88
+ dir_counts[top] += 1
89
+
90
+ # Build module map from top dirs
91
+ modules = {}
92
+ for d, _ in dir_counts.most_common(15):
93
+ if d in _KNOWN_PATTERNS:
94
+ modules[d] = _KNOWN_PATTERNS[d]
95
+ else:
96
+ # Capitalize the directory name as display name
97
+ modules[d] = d.replace("_", " ").replace("-", " ").title()
98
+
99
+ # Assign colors
100
+ colors = {}
101
+ for i, d in enumerate(modules):
102
+ colors[d] = _PALETTE[i % len(_PALETTE)]
103
+
104
+ return {"modules": modules, "colors": colors}
105
+
106
+
107
+ def _fallback_detect(repo: Path) -> dict:
108
+ """Fallback: scan filesystem if git log fails."""
109
+ modules = {}
110
+ for p in sorted(repo.iterdir()):
111
+ if p.is_dir() and not p.name.startswith("."):
112
+ name = p.name
113
+ if name in _KNOWN_PATTERNS:
114
+ modules[name] = _KNOWN_PATTERNS[name]
115
+ elif name not in ("node_modules", "venv", ".venv", "__pycache__",
116
+ "dist", "build", ".next", "target"):
117
+ modules[name] = name.replace("_", " ").replace("-", " ").title()
118
+ colors = {d: _PALETTE[i % len(_PALETTE)] for i, d in enumerate(modules)}
119
+ return {"modules": modules, "colors": colors}
@@ -0,0 +1,510 @@
1
+ """Core report generator — config-driven, works with any repo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from collections import Counter, defaultdict
9
+ from dataclasses import dataclass, field as dc_field
10
+ from datetime import date, datetime, timedelta
11
+ from pathlib import Path
12
+
13
+ from .config import ProgressConfig
14
+
15
+
16
+ @dataclass
17
+ class Commit:
18
+ sha: str
19
+ author: str
20
+ authored_at: datetime
21
+ subject: str
22
+ additions: int = 0
23
+ deletions: int = 0
24
+ file_count: int = 0
25
+ surfaces: Counter = dc_field(default_factory=Counter)
26
+
27
+ @property
28
+ def churn(self) -> int:
29
+ return self.additions + self.deletions
30
+
31
+
32
+ _SEP = "__COMMIT__"
33
+
34
+
35
+ def _git(repo: Path, *args: str) -> str:
36
+ return subprocess.run(
37
+ ["git", "-C", str(repo), *args],
38
+ check=True, capture_output=True, text=True,
39
+ ).stdout
40
+
41
+
42
+ def _make_classifier(config: ProgressConfig):
43
+ """Build a classify function from config modules."""
44
+ # Sort by longest prefix first for correct matching
45
+ prefixes = sorted(config.modules.keys(), key=len, reverse=True)
46
+
47
+ def classify(path: str) -> str:
48
+ for prefix in prefixes:
49
+ if path.startswith(prefix + "/") or path.startswith(prefix):
50
+ return config.modules[prefix]
51
+ return "Other"
52
+
53
+ return classify
54
+
55
+
56
+ def parse_commits(repo: Path, classify) -> list[Commit]:
57
+ fmt = f"{_SEP}%n%H%x1f%an%x1f%ad%x1f%s"
58
+ raw = _git(repo, "log", "--use-mailmap", "--reverse",
59
+ "--date=iso-strict", "--numstat", f"--format={fmt}", "HEAD")
60
+ commits: list[Commit] = []
61
+ cur: Commit | None = None
62
+ want_meta = False
63
+
64
+ for line in raw.splitlines():
65
+ if line == _SEP:
66
+ if cur is not None:
67
+ commits.append(cur)
68
+ cur = None
69
+ want_meta = True
70
+ continue
71
+ if want_meta:
72
+ sha, author, dt_s, subj = line.split("\x1f", 3)
73
+ cur = Commit(sha=sha, author=author,
74
+ authored_at=datetime.fromisoformat(dt_s.replace("Z", "+00:00")),
75
+ subject=subj)
76
+ want_meta = False
77
+ continue
78
+ if cur is None or not line.strip():
79
+ continue
80
+ parts = line.split("\t", 2)
81
+ if len(parts) != 3:
82
+ continue
83
+ a_s, d_s, path = parts
84
+ cur.file_count += 1
85
+ if a_s == "-" or d_s == "-":
86
+ continue
87
+ a, d = int(a_s), int(d_s)
88
+ cur.additions += a
89
+ cur.deletions += d
90
+ cur.surfaces[classify(path)] += a + d
91
+
92
+ if cur is not None:
93
+ commits.append(cur)
94
+ return commits
95
+
96
+
97
+ def _relative_time(dt: datetime) -> str:
98
+ now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now()
99
+ diff = now - dt
100
+ minutes = int(diff.total_seconds() / 60)
101
+ if minutes < 1:
102
+ return "just now"
103
+ if minutes < 60:
104
+ return f"{minutes}m ago"
105
+ hours = minutes // 60
106
+ if hours < 24:
107
+ return f"{hours}h ago"
108
+ days = hours // 24
109
+ return f"{days}d ago"
110
+
111
+
112
+ def _classify_type(subject: str) -> str:
113
+ s = subject.lower().strip()
114
+ for t in ("feat", "fix", "refactor", "docs", "test", "chore", "style", "perf"):
115
+ if s.startswith(t):
116
+ return t
117
+ return "other"
118
+
119
+
120
+ def _extract_scope(subject: str) -> str:
121
+ m = re.match(r'\w+\(([^)]+)\)', subject)
122
+ return m.group(1) if m else ""
123
+
124
+
125
+ def longest_streak(active_dates: set[date]) -> int:
126
+ if not active_dates:
127
+ return 0
128
+ d = min(active_dates)
129
+ end = max(active_dates)
130
+ best = cur = 0
131
+ while d <= end:
132
+ if d in active_dates:
133
+ cur += 1
134
+ best = max(best, cur)
135
+ else:
136
+ cur = 0
137
+ d += timedelta(days=1)
138
+ return best
139
+
140
+
141
+ def build_report(repo: Path, output: Path, config: ProgressConfig) -> None:
142
+ classify = _make_classifier(config)
143
+ commits = parse_commits(repo, classify)
144
+ if not commits:
145
+ raise SystemExit("No commits found.")
146
+
147
+ first = commits[0].authored_at.date()
148
+ last = commits[-1].authored_at.date()
149
+ span = (last - first).days + 1
150
+ total = len(commits)
151
+ total_churn = sum(c.churn for c in commits)
152
+
153
+ active_set: set[date] = {c.authored_at.date() for c in commits}
154
+ active_days = len(active_set)
155
+ streak = longest_streak(active_set)
156
+
157
+ # Daily
158
+ daily_commits: dict[str, int] = defaultdict(int)
159
+ for c in commits:
160
+ daily_commits[c.authored_at.date().isoformat()] += 1
161
+
162
+ daily_labels, daily_values = [], []
163
+ d = last
164
+ while d >= first:
165
+ daily_labels.append(f"{d.month}/{d.day}")
166
+ daily_values.append(daily_commits.get(d.isoformat(), 0))
167
+ d -= timedelta(days=1)
168
+
169
+ # Monthly
170
+ monthly_churn: dict[str, int] = defaultdict(int)
171
+ for c in commits:
172
+ monthly_churn[c.authored_at.strftime("%Y-%m")] += c.churn
173
+ months = sorted(monthly_churn)
174
+ month_labels = [m.split("-")[1].lstrip("0") + "月" for m in months]
175
+ month_churn_vals = [monthly_churn[m] for m in months]
176
+
177
+ # Surface totals
178
+ surface_totals: Counter = Counter()
179
+ for c in commits:
180
+ surface_totals.update(c.surfaces)
181
+ top_surfaces = [(s, v) for s, v in surface_totals.most_common(12) if v > 0 and s != "Other"]
182
+
183
+ surface_labels = [s for s, _ in top_surfaces]
184
+ surface_values = [v for _, v in top_surfaces]
185
+ surface_colors = [config.colors.get(
186
+ next((k for k, v2 in config.modules.items() if v2 == s), ""),
187
+ "#1D4ED8"
188
+ ) for s, _ in top_surfaces]
189
+
190
+ # Module monthly trends
191
+ top_keys = [s for s, _ in top_surfaces[:6]]
192
+ module_monthly = {s: [] for s in top_keys}
193
+ for m in months:
194
+ ms: Counter = Counter()
195
+ for c in commits:
196
+ if c.authored_at.strftime("%Y-%m") == m:
197
+ ms.update(c.surfaces)
198
+ for s in top_keys:
199
+ module_monthly[s].append(ms.get(s, 0))
200
+
201
+ module_trend = []
202
+ for s in top_keys:
203
+ color = config.colors.get(
204
+ next((k for k, v2 in config.modules.items() if v2 == s), ""), "#1D4ED8")
205
+ module_trend.append({
206
+ "label": s, "data": module_monthly[s],
207
+ "borderColor": color, "backgroundColor": color + "30",
208
+ "fill": True, "tension": 0.4, "borderWidth": 2,
209
+ })
210
+
211
+ # Peak
212
+ peak_day_str = max(daily_commits, key=daily_commits.get)
213
+ peak_day_val = daily_commits[peak_day_str]
214
+
215
+ # Velocity
216
+ cutoff = last - timedelta(days=30)
217
+ recent = [c for c in commits if c.authored_at.date() > cutoff]
218
+ earlier = [c for c in commits if c.authored_at.date() <= cutoff]
219
+ rd = max(len({c.authored_at.date() for c in recent}), 1)
220
+ ed = max(len({c.authored_at.date() for c in earlier}), 1)
221
+ rv = sum(c.churn for c in recent) / rd
222
+ ev = sum(c.churn for c in earlier) / ed
223
+ vel_change = ((rv - ev) / max(ev, 1)) * 100
224
+
225
+ # Commit types
226
+ type_counts: Counter = Counter()
227
+ for c in commits:
228
+ type_counts[_classify_type(c.subject)] += 1
229
+
230
+ # Changelog
231
+ changelog = []
232
+ for c in reversed(commits):
233
+ ct = _classify_type(c.subject)
234
+ if ct in ("feat", "fix", "refactor", "perf"):
235
+ clean = re.sub(r'^\w+(\([^)]*\))?[:\s]*', '', c.subject).strip()
236
+ surfs = [s for s, _ in c.surfaces.most_common(3) if _ > 0]
237
+ changelog.append({
238
+ "type": ct, "scope": _extract_scope(c.subject),
239
+ "subject": clean[:120], "date": c.authored_at.strftime("%m/%d"),
240
+ "churn": c.churn, "additions": c.additions, "deletions": c.deletions,
241
+ "files": c.file_count, "surfaces": surfs, "sha": c.sha[:7],
242
+ })
243
+ if len(changelog) >= 20:
244
+ break
245
+
246
+ # Weekly
247
+ weekly: dict[str, int] = defaultdict(int)
248
+ for c in commits:
249
+ weekly[c.authored_at.strftime("%Y-W%W")] += c.churn
250
+ ws = sorted(weekly.keys())
251
+ weekly_values = [weekly[w] for w in ws]
252
+ weekly_labels = [f"W{w.split('W')[1]}" for w in ws]
253
+
254
+ # Authors
255
+ authors = set()
256
+ for c in commits:
257
+ authors.add(c.author)
258
+
259
+ chart_data = json.dumps({
260
+ "dailyLabels": daily_labels, "dailyValues": daily_values,
261
+ "monthLabels": month_labels, "monthChurn": month_churn_vals,
262
+ "surfaceLabels": surface_labels, "surfaceValues": surface_values,
263
+ "surfaceColors": surface_colors, "moduleTrend": module_trend,
264
+ "weeklyLabels": weekly_labels, "weeklyValues": weekly_values,
265
+ }, ensure_ascii=False)
266
+
267
+ now = datetime.now().strftime("%Y-%m-%d %H:%M")
268
+
269
+ # Resolve accent colors from config
270
+ accent = config.accent_color or "#1D4ED8"
271
+ accent2 = config.accent_secondary or "#EA580C"
272
+ bg = config.background or "#F7F4F0"
273
+ font = config.font_family or "'Inter','PingFang SC','Noto Sans SC',-apple-system,sans-serif"
274
+
275
+ html = _render(
276
+ config=config,
277
+ total=total, total_churn=total_churn,
278
+ active_days=active_days, span=span, streak=streak,
279
+ peak_day_val=peak_day_val, vel_change=vel_change,
280
+ recent_velocity=int(rv), type_counts=dict(type_counts.most_common()),
281
+ changelog=changelog, chart_data_json=chart_data,
282
+ generated_at=now, first_date=first, last_date=last,
283
+ last_commit=commits[-1], author_count=len(authors),
284
+ accent=accent, accent2=accent2, bg=bg, font=font,
285
+ )
286
+
287
+ output.parent.mkdir(parents=True, exist_ok=True)
288
+ output.write_text(html, encoding="utf-8")
289
+ print(f"\n ✦ {config.title}")
290
+ print(f" {total:,} commits · {active_days} active days · {total_churn:,} lines changed")
291
+ print(f" → {output}\n")
292
+
293
+
294
+ def _render(
295
+ config: ProgressConfig,
296
+ total: int, total_churn: int,
297
+ active_days: int, span: int, streak: int,
298
+ peak_day_val: int, vel_change: float,
299
+ recent_velocity: int, type_counts: dict,
300
+ changelog: list[dict], chart_data_json: str,
301
+ generated_at: str, first_date: date, last_date: date,
302
+ last_commit: Commit, author_count: int,
303
+ accent: str = "#1D4ED8", accent2: str = "#EA580C",
304
+ bg: str = "#F7F4F0", font: str = "",
305
+ ) -> str:
306
+ vel_arrow = "↑" if vel_change >= 0 else "↓"
307
+ vel_color = "#059669" if vel_change >= 0 else "#DC2626"
308
+ vel_label = "accelerating" if vel_change >= 0 else "decelerating"
309
+
310
+ feat_count = type_counts.get("feat", 0)
311
+ fix_count = type_counts.get("fix", 0)
312
+ refactor_count = type_counts.get("refactor", 0)
313
+ last_time = _relative_time(last_commit.authored_at)
314
+
315
+ subtitle = config.subtitle or f"{author_count} contributor{'s' if author_count > 1 else ''} · {first_date.strftime('%Y.%m.%d')} — {last_date.strftime('%Y.%m.%d')}"
316
+
317
+ # Changelog HTML
318
+ type_badges = {
319
+ "feat": '<span class="badge badge-feat">NEW</span>',
320
+ "fix": '<span class="badge badge-fix">FIX</span>',
321
+ "refactor": '<span class="badge badge-refactor">REFACTOR</span>',
322
+ "perf": '<span class="badge badge-perf">PERF</span>',
323
+ }
324
+ cl_rows = ""
325
+ for e in changelog:
326
+ badge = type_badges.get(e["type"], "")
327
+ scope = f'<span class="cl-scope">{e["scope"]}</span>' if e["scope"] else ""
328
+ surfs = " ".join(f'<span class="cl-surf">{s}</span>' for s in e.get("surfaces", []))
329
+ cl_rows += f"""
330
+ <div class="cl-item">
331
+ <div class="cl-row" onclick="this.parentElement.classList.toggle('open')">
332
+ <div class="cl-date">{e["date"]}</div>
333
+ <div class="cl-badge">{badge}</div>
334
+ <div class="cl-body">{scope}{e["subject"]}</div>
335
+ <div class="cl-churn">{e["churn"]:,} lines</div>
336
+ <div class="cl-arrow">&#9662;</div>
337
+ </div>
338
+ <div class="cl-detail">
339
+ <span class="cl-sha">{e["sha"]}</span>
340
+ <span class="cl-add">+{e["additions"]:,}</span>
341
+ <span class="cl-del">-{e["deletions"]:,}</span>
342
+ <span class="cl-files">{e["files"]} files</span>
343
+ {surfs}
344
+ </div>
345
+ </div>"""
346
+
347
+ branding = ""
348
+ if config.branding:
349
+ branding = """<div class="branding">
350
+ Powered by <a href="https://towow.net" target="_blank" rel="noopener">Towow</a> — AI-native collaboration protocol
351
+ </div>"""
352
+
353
+ # The full HTML template (same design system as Towow's own page)
354
+ return f"""<!DOCTYPE html>
355
+ <html lang="en">
356
+ <head>
357
+ <meta charset="utf-8"/>
358
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
359
+ <title>{config.title}</title>
360
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
361
+ <style>
362
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
363
+ *{{box-sizing:border-box;margin:0;padding:0}}
364
+ :root{{--bg:{bg};--bg-warm:#F0EBE4;--blue:{accent};--blue-deep:{accent};--blue-light:{accent}CC;--orange:{accent2};--orange-light:{accent2}AA;--text:#1E293B;--text-2:#475569;--text-3:#94A3B8;--border:rgba(30,58,138,0.08)}}
365
+ body{{background:var(--bg);color:var(--text);font-family:{font if font else "'Inter','PingFang SC','Noto Sans SC',-apple-system,sans-serif"};line-height:1.6;-webkit-font-smoothing:antialiased;min-height:100vh}}
366
+ body::before{{content:'';display:block;height:6px;background:linear-gradient(90deg,var(--blue),var(--blue-light) 40%,var(--orange) 70%,var(--orange-light))}}
367
+ .page{{max-width:1200px;margin:0 auto;padding:80px 48px 100px}}
368
+ @keyframes fadeUp{{from{{opacity:0;transform:translateY(24px)}}to{{opacity:1;transform:translateY(0)}}}}
369
+ .hero{{margin-bottom:96px;animation:fadeUp .8s ease-out}}
370
+ .hero-title{{font-size:80px;font-weight:900;letter-spacing:-.04em;line-height:1;color:var(--blue-deep);margin-bottom:16px}}
371
+ .hero-title .accent{{color:var(--orange)}}
372
+ .hero-sub{{font-size:22px;color:var(--text-2);font-weight:500;margin-bottom:48px}}
373
+ .hero-sub strong{{color:var(--text);font-weight:800}}
374
+ .live-pulse{{display:flex;align-items:center;gap:12px;margin-bottom:32px;font-size:14px;color:var(--text-3)}}
375
+ .pulse-dot{{width:10px;height:10px;border-radius:50%;background:#059669;position:relative}}
376
+ .pulse-dot::before{{content:'';position:absolute;inset:-4px;border-radius:50%;background:rgba(5,150,105,.3);animation:pulse 2s ease-in-out infinite}}
377
+ @keyframes pulse{{0%,100%{{transform:scale(1);opacity:.6}}50%{{transform:scale(1.8);opacity:0}}}}
378
+ .live-pulse .sha{{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--blue);background:rgba(29,78,216,.06);padding:2px 8px;border-radius:4px;font-weight:600}}
379
+ .live-pulse .msg{{color:var(--text-2);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:600px}}
380
+ .hero-numbers{{display:grid;grid-template-columns:repeat(4,1fr);border:2px solid var(--blue);border-radius:16px;overflow:hidden;background:#fff}}
381
+ .hero-num{{padding:32px 28px;border-right:1px solid var(--border);animation:fadeUp .8s ease-out backwards}}
382
+ .hero-num:last-child{{border-right:none}}
383
+ .hero-num .val{{font-size:42px;font-weight:900;letter-spacing:-.03em;color:var(--blue-deep);line-height:1.1;white-space:nowrap}}
384
+ .hero-num .val .unit{{font-size:20px;font-weight:700;color:var(--blue-light)}}
385
+ .hero-num .lbl{{font-size:15px;color:var(--text-3);font-weight:600;margin-top:4px;text-transform:uppercase;letter-spacing:.06em}}
386
+ .velocity-banner{{margin-top:32px;display:flex;align-items:center;gap:24px;padding:20px 28px;background:#fff;border-radius:12px;border-left:4px solid {vel_color}}}
387
+ .velocity-banner .vel-num{{font-size:32px;font-weight:900;color:{vel_color}}}
388
+ .velocity-banner .vel-detail{{font-size:16px;color:var(--text-2)}}
389
+ .velocity-banner .vel-detail strong{{color:var(--text)}}
390
+ .section{{margin-bottom:80px;animation:fadeUp .7s ease-out backwards}}
391
+ .section-header{{display:flex;align-items:baseline;gap:16px;margin-bottom:28px}}
392
+ .section-num{{font-size:14px;font-weight:800;color:var(--orange);letter-spacing:.08em}}
393
+ .section-title{{font-size:36px;font-weight:800;letter-spacing:-.02em;color:var(--blue-deep)}}
394
+ .chart-card{{background:#fff;border-radius:16px;padding:32px;border:1px solid var(--border)}}
395
+ .chart-wrap{{height:400px;position:relative}}
396
+ .chart-wrap canvas{{width:100%!important;height:100%!important}}
397
+ .timeline-card{{position:relative;padding:0;background:none;border:none;border-radius:0}}
398
+ .timeline-scroll{{overflow-x:auto;overflow-y:hidden;padding:0 0 12px;scrollbar-width:thin;scrollbar-color:var(--blue-light) transparent}}
399
+ .timeline-scroll::-webkit-scrollbar{{height:6px}}
400
+ .timeline-scroll::-webkit-scrollbar-thumb{{background:var(--blue-light);border-radius:3px}}
401
+ .timeline-scroll canvas{{height:720px}}
402
+ .grid-2{{display:grid;grid-template-columns:1fr 1fr;gap:32px}}
403
+ .grid-2 .chart-card{{padding:28px}}
404
+ .grid-2 .chart-label{{font-size:18px;font-weight:700;color:var(--text);margin-bottom:16px}}
405
+ .grid-2 .chart-box{{height:360px;position:relative}}
406
+ .grid-2 canvas{{width:100%!important;height:100%!important}}
407
+ .changelog{{background:#fff;border-radius:16px;border:1px solid var(--border);overflow:hidden}}
408
+ .cl-item{{border-bottom:1px solid rgba(0,0,0,.04)}}
409
+ .cl-item:last-child{{border-bottom:none}}
410
+ .cl-row{{display:grid;grid-template-columns:56px 90px 1fr auto 24px;align-items:center;gap:12px;padding:14px 24px;font-size:15px;cursor:pointer;transition:background .15s}}
411
+ .cl-row:hover{{background:rgba(29,78,216,.03)}}
412
+ .cl-arrow{{font-size:12px;color:var(--text-3);transition:transform .2s}}
413
+ .cl-item.open .cl-arrow{{transform:rotate(180deg)}}
414
+ .cl-detail{{display:none;padding:0 24px 14px 170px;font-size:13px;color:var(--text-3);gap:10px;align-items:center;flex-wrap:wrap}}
415
+ .cl-item.open .cl-detail{{display:flex}}
416
+ .cl-sha{{font-family:'SF Mono','Fira Code',monospace;background:rgba(29,78,216,.06);color:var(--blue);padding:2px 8px;border-radius:4px;font-weight:600}}
417
+ .cl-add{{color:#059669;font-weight:700}}.cl-del{{color:#DC2626;font-weight:700}}
418
+ .cl-files{{color:var(--text-3)}}.cl-surf{{background:var(--bg-warm);color:var(--text-2);padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600}}
419
+ .cl-date{{font-size:14px;color:var(--text-3);font-weight:600;font-variant-numeric:tabular-nums}}
420
+ .cl-body{{color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
421
+ .cl-scope{{display:inline-block;background:var(--bg-warm);color:var(--blue);font-size:12px;font-weight:700;padding:2px 8px;border-radius:4px;margin-right:8px;text-transform:uppercase}}
422
+ .cl-churn{{font-size:13px;color:var(--text-3);font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}}
423
+ .badge{{display:inline-block;padding:3px 10px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.05em}}
424
+ .badge-feat{{background:#DBEAFE;color:#1D4ED8}}.badge-fix{{background:#FEE2E2;color:#DC2626}}
425
+ .badge-refactor{{background:#EDE9FE;color:#7C3AED}}.badge-perf{{background:#CCFBF1;color:#0D9488}}
426
+ .type-stats{{display:flex;gap:32px;margin-top:32px}}
427
+ .type-stat{{display:flex;align-items:center;gap:10px;background:#fff;padding:16px 24px;border-radius:12px;border:1px solid var(--border);flex:1}}
428
+ .type-stat .ts-num{{font-size:28px;font-weight:900;color:var(--blue-deep)}}
429
+ .type-stat .ts-lbl{{font-size:14px;color:var(--text-3);font-weight:600}}
430
+ .divider{{height:2px;margin:0 0 80px;background:linear-gradient(90deg,var(--blue),var(--orange-light) 50%,transparent);opacity:.15}}
431
+ .footer{{text-align:center;color:var(--text-3);font-size:14px;padding-top:48px;border-top:1px solid var(--border)}}
432
+ .branding{{text-align:center;margin-top:24px;font-size:13px;color:var(--text-3)}}
433
+ .branding a{{color:var(--blue);text-decoration:none;font-weight:600}}
434
+ .branding a:hover{{text-decoration:underline}}
435
+ @media(max-width:900px){{.page{{padding:48px 20px 60px}}.hero-title{{font-size:48px}}.hero-numbers{{grid-template-columns:repeat(2,1fr)}}.grid-2{{grid-template-columns:1fr}}.type-stats{{flex-wrap:wrap;gap:12px}}.cl-row{{grid-template-columns:48px 70px 1fr}}.cl-churn{{display:none}}.velocity-banner{{flex-direction:column;gap:12px}}}}
436
+ </style>
437
+ </head>
438
+ <body>
439
+ <main class="page">
440
+ <section class="hero">
441
+ <h1 class="hero-title">{config.title}</h1>
442
+ <p class="hero-sub">{subtitle}</p>
443
+ <div class="live-pulse">
444
+ <div class="pulse-dot"></div>
445
+ <span>Latest commit</span>
446
+ <span class="sha">{last_commit.sha[:7]}</span>
447
+ <span class="msg">{last_commit.subject[:80]}</span>
448
+ <span style="margin-left:auto;font-variant-numeric:tabular-nums">{last_time}</span>
449
+ </div>
450
+ <div class="hero-numbers">
451
+ <div class="hero-num"><div class="val">{total:,}</div><div class="lbl">Commits</div></div>
452
+ <div class="hero-num"><div class="val">{total_churn:,} <span class="unit">lines</span></div><div class="lbl">Code changed</div></div>
453
+ <div class="hero-num"><div class="val">{active_days}<span class="unit"> / {span}</span></div><div class="lbl">Active days</div></div>
454
+ <div class="hero-num"><div class="val">{streak}<span class="unit"> days</span></div><div class="lbl">Best streak</div></div>
455
+ </div>
456
+ <div class="velocity-banner">
457
+ <div class="vel-num">{vel_arrow} {abs(vel_change):.0f}%</div>
458
+ <div class="vel-detail">
459
+ Last 30 days: <strong>{recent_velocity:,} lines/day</strong>, {vel_label}.
460
+ Peak day: <strong>{peak_day_val} commits</strong>.
461
+ </div>
462
+ </div>
463
+ </section>
464
+
465
+ <section class="section"><div class="section-header"><span class="section-num">01</span><h2 class="section-title">Commit Timeline</h2></div>
466
+ <div class="chart-card timeline-card"><div class="timeline-scroll" id="timelineScroll"><canvas id="dailyChart"></canvas></div></div>
467
+ </section><div class="divider"></div>
468
+
469
+ <section class="section"><div class="section-header"><span class="section-num">02</span><h2 class="section-title">Module Trends</h2></div>
470
+ <div class="chart-card"><div class="chart-wrap"><canvas id="moduleTrendChart"></canvas></div></div>
471
+ </section><div class="divider"></div>
472
+
473
+ <section class="section"><div class="section-header"><span class="section-num">03</span><h2 class="section-title">Distribution & Velocity</h2></div>
474
+ <div class="grid-2">
475
+ <div class="chart-card"><div class="chart-label">Code by Module</div><div class="chart-box"><canvas id="surfaceChart"></canvas></div></div>
476
+ <div class="chart-card"><div class="chart-label">Weekly Velocity</div><div class="chart-box"><canvas id="weeklyChart"></canvas></div></div>
477
+ </div>
478
+ </section><div class="divider"></div>
479
+
480
+ <section class="section"><div class="section-header"><span class="section-num">04</span><h2 class="section-title">Recent Activity</h2></div>
481
+ <div class="changelog">{cl_rows}</div>
482
+ <div class="type-stats">
483
+ <div class="type-stat"><div class="ts-num">{feat_count}</div><div class="ts-lbl">Features</div></div>
484
+ <div class="type-stat"><div class="ts-num">{fix_count}</div><div class="ts-lbl">Fixes</div></div>
485
+ <div class="type-stat"><div class="ts-num">{refactor_count}</div><div class="ts-lbl">Refactors</div></div>
486
+ <div class="type-stat"><div class="ts-num">{streak}</div><div class="ts-lbl">Day streak</div></div>
487
+ </div>
488
+ </section>
489
+
490
+ <footer class="footer">
491
+ <p>Generated {generated_at} · {first_date.isoformat()} → {last_date.isoformat()}</p>
492
+ {branding}
493
+ </footer>
494
+ </main>
495
+ <script>
496
+ const D={chart_data_json};
497
+ Chart.defaults.color='#94A3B8';Chart.defaults.borderColor='rgba(0,0,0,0.06)';
498
+ Chart.defaults.font.family="'Inter','PingFang SC',sans-serif";Chart.defaults.font.size=13;
499
+ const TT={{backgroundColor:'#1E293B',titleColor:'#F8FAFC',bodyColor:'#CBD5E1',titleFont:{{size:16,weight:'800'}},bodyFont:{{size:14}},padding:14,cornerRadius:10,borderColor:'rgba(29,78,216,0.2)',borderWidth:1,displayColors:false}};
500
+ (()=>{{const c=document.getElementById('dailyChart');const v=D.dailyValues;const mx=Math.max(...v);const minW=Math.max(v.length*24,1200);c.style.width=minW+'px';c.style.minWidth=minW+'px';const cols=v.map(x=>{{if(!x)return'{accent}10';const t=x/mx;return t>.8?'{accent2}':t>.5?'{accent}':`{accent}${{Math.round((.25+.55*t)*255).toString(16).padStart(2,'0')}}`}});new Chart(c,{{type:'bar',data:{{labels:D.dailyLabels,datasets:[{{data:v,backgroundColor:cols,borderRadius:5,borderSkipped:false,barPercentage:.92,categoryPercentage:.95}}]}},options:{{responsive:false,maintainAspectRatio:false,plugins:{{legend:{{display:false}},tooltip:{{...TT,callbacks:{{title:i=>i[0].label,label:i=>i.raw+' commits'}}}}}},scales:{{x:{{grid:{{display:false}},ticks:{{maxRotation:0,autoSkip:true,maxTicksLimit:14,font:{{size:16,weight:'500'}},color:'#64748B'}}}},y:{{beginAtZero:true,grid:{{color:'rgba(0,0,0,0.04)'}},ticks:{{font:{{size:18,weight:'600'}},color:'#64748B',maxTicksLimit:6}}}}}},animation:{{duration:1200,easing:'easeOutQuart'}}}}}})}}
501
+ )();
502
+ (()=>{{new Chart(document.getElementById('moduleTrendChart'),{{type:'line',data:{{labels:D.monthLabels,datasets:D.moduleTrend}},options:{{responsive:true,maintainAspectRatio:false,plugins:{{legend:{{position:'top',labels:{{usePointStyle:true,pointStyle:'circle',padding:20,font:{{size:13,weight:'600'}}}}}},tooltip:{{...TT,displayColors:true,callbacks:{{label:i=>i.dataset.label+': '+Number(i.raw).toLocaleString()+' lines'}}}}}},scales:{{x:{{grid:{{display:false}},ticks:{{font:{{size:15,weight:'700'}},color:'#64748B'}}}},y:{{stacked:true,beginAtZero:true,grid:{{color:'rgba(0,0,0,0.04)'}},ticks:{{font:{{size:13}},color:'#94A3B8',callback:v=>v>=1000?(v/1000).toFixed(0)+'K':v}}}}}},animation:{{duration:1400,easing:'easeOutQuart'}}}}}})}}
503
+ )();
504
+ (()=>{{new Chart(document.getElementById('surfaceChart'),{{type:'bar',data:{{labels:D.surfaceLabels,datasets:[{{data:D.surfaceValues,backgroundColor:D.surfaceColors,borderRadius:6,borderSkipped:false,barPercentage:.7}}]}},options:{{indexAxis:'y',responsive:true,maintainAspectRatio:false,plugins:{{legend:{{display:false}},tooltip:{{...TT,displayColors:true,callbacks:{{label:i=>Number(i.raw).toLocaleString()+' lines'}}}}}},scales:{{x:{{grid:{{color:'rgba(0,0,0,0.04)'}},ticks:{{font:{{size:13}},color:'#94A3B8',callback:v=>v>=1000?(v/1000).toFixed(0)+'K':v}}}},y:{{grid:{{display:false}},ticks:{{font:{{size:14,weight:'600'}},color:'#1E293B'}}}}}},animation:{{duration:1200,easing:'easeOutQuart'}}}}}})}}
505
+ )();
506
+ (()=>{{const c=document.getElementById('weeklyChart').getContext('2d');const g=c.createLinearGradient(0,0,0,360);g.addColorStop(0,'{accent}33');g.addColorStop(1,'{accent}05');new Chart(c,{{type:'line',data:{{labels:D.weeklyLabels,datasets:[{{data:D.weeklyValues,fill:true,backgroundColor:g,borderColor:'{accent}',borderWidth:2.5,tension:.4,pointRadius:4,pointBackgroundColor:'#fff',pointBorderColor:'{accent}',pointBorderWidth:2.5,pointHoverRadius:7,pointHoverBackgroundColor:'{accent}'}}]}},options:{{responsive:true,maintainAspectRatio:false,plugins:{{legend:{{display:false}},tooltip:{{...TT,callbacks:{{label:i=>Number(i.raw).toLocaleString()+' lines'}}}}}},scales:{{x:{{grid:{{display:false}},ticks:{{maxRotation:0,autoSkip:true,maxTicksLimit:10,font:{{size:12}},color:'#94A3B8'}}}},y:{{beginAtZero:true,grid:{{color:'rgba(0,0,0,0.04)'}},ticks:{{font:{{size:13}},color:'#94A3B8',callback:v=>v>=1000?(v/1000).toFixed(0)+'K':v}}}}}},animation:{{duration:1400,easing:'easeOutQuart'}}}}}})}}
507
+ )();
508
+ </script>
509
+ </body>
510
+ </html>"""