docs: resync after card fixes — title auto-fit, truncate, abbreviated ticks (#15)

Several recent code changes hadn't propagated to the docs:

- design-guidelines
  * Card frame title row: document the 11–15 px auto-shrink (not a flat
    15 px anymore).
  * Donut Top-N: already 7 (updated earlier).
  * Bar-chart section renamed to cover weekday + by-year too; document
    the peak-vs-dim highlight convention and the niceTicks yMax ≥ max
    invariant.
  * Add Heatmap / Stat-column (streak) / List (top-starred) card
    sections — they were missing entirely.
  * Rewrite "Text overflow" from "we don't truncate" to the current
    truth (truncate helper, formatTick abbreviations).
  * Replace dangling `truncateName` reference with `truncate`.

- code-standards
  * SVG output standards: call out truncate, formatTick abbreviation,
    header auto-fit so the card-review gate reflects what the renderers
    actually do.

- codebase-summary
  * Layout tree: svg.go / axis.go comments list the helpers they now
    contain; productive.go notes the weekday histogram.
  * demo/ tree shows the index vs per-theme split.
  * Data-flow diagram includes Weekday in the productive pass.
  * Test coverage row lists TestCardsFitFrame / TestFitTitleFontSize /
    TestNiceTicksCoversMax — the new invariant guards.

- system-architecture
  * Shared primitives list adds renderWeekday and renderHeatmap; donut
    blurb updated to "top 7".
  * New "Chart-geometry invariants" block documents niceTicks ceiling,
    formatTick abbreviation, header auto-fit.

- project-roadmap
  * Phase 7.5 bullet updated to describe the index + per-theme demo
    split (was single-README TOC).
This commit is contained in:
tiennm99 authored and GitHub committed 2026-04-19 10:22:03 +07:00
1 parent fc29729aa2
commit 88397e80a3
5 files changed
+67 -22

No files matched your search

+3 -1
View File
@@ -57,8 +57,10 @@ Never write comments that describe what well-named code does (`// increment coun
## SVG output standards
- Always XML-escape user-controlled strings through `escapeXML` (`&`, `<`, `>`, `"`, `'`).
- Numbers formatted via `formatInt` with thousands separators.
- Long user-controlled strings (bio, names, company, repo slugs) go through `truncate` in `svg.go` before rendering — never print raw multi-line input that could push a later element off-screen.
- Body numbers formatted via `formatInt` (thousands separators). Y-axis tick numbers go through `formatTick`, which abbreviates ≥ 1000 to `k` / `M` / `B` so no label exceeds 4 chars.
- Stable viewbox per card (`340×200`) matching github-profile-summary-cards.
- Titles render through `header()` which auto-shrinks font-size (15 → 11 px) when the string wouldn't fit in `width − 20 − 4`.
- No `<script>` tags, no event handlers. Cards are pure markup.
## Testing
+9 -7
View File
@@ -15,13 +15,13 @@ ghstats/
│ │ ├── queries.go # profileQuery, commitHistoryQuery, contributionYearQuery
│ │ ├── model.go # Profile, RepoInfo, LangStat, LangEdge, DailyContribution
│ │ ├── profile.go # FetchProfile — user + owned repos + stats + calendar
│ │ ├── productive.go # FetchProductive — commit history → hour histogram + lang buckets
│ │ ├── productive.go # FetchProductive — commit history → hour + weekday histograms + lang buckets
│ │ ├── contributions_all_time.go # FetchContributionsAllTime — per-year loop → seed list + daily series
│ │ └── profile_test.go # sortLangStats tiebreak
│ ├── card/ # SVG renderers; one file per card
│ │ ├── card.go # Card interface, RenderAll, allCards slice
│ │ ├── svg.go # escapeXML, formatInt, header, footer
│ │ ├── axis.go # niceTicks (d3-style 1/2/5 × 10^k), formatTick
│ │ ├── svg.go # escapeXML, formatInt, truncate, header (auto-fit title), footer
│ │ ├── axis.go # niceTicks (d3-style, last tick ≥ max), formatTick (1500→"1.5k")
│ │ ├── icons.go # Octicon path strings
│ │ ├── profile.go # profile-details
│ │ ├── repos_per_language.go # repos-per-language
@@ -45,8 +45,10 @@ ghstats/
│ └── demo.yml # Renders every theme for the repo owner on push to main
├── docs/ # This directory
├── plans/ # Research reports + implementation plans
└── demo/ # Auto-generated gallery — every card × every theme + README
# (`output/` is entirely gitignored; see demo/ for reference renders)
└── demo/ # Auto-generated gallery
├── README.md # Lightweight index (links only, zero images)
└── <theme>/ # Per-theme page: 15 SVGs + README pairing LY / AT variants
# (`output/` is entirely gitignored; see demo/ for reference renders)
```
## Module responsibilities
@@ -99,7 +101,7 @@ contributionYearQuery ─┬──► SeedRepos + DailyContributionsAllTime + To
│
└─ seed into ─►
│
commitHistoryQuery ──► Productive + CommitsByLanguage (+ AllTime variants)
commitHistoryQuery ──► Productive + Weekday + CommitsByLanguage (+ AllTime variants)
│
▼
15 SVG files per theme
@@ -107,7 +109,7 @@ commitHistoryQuery ──► Productive + CommitsByLanguage (+ AllTime variants)
## Test coverage
- `internal/card/card_test.go` — `RenderAll` produces 15 valid SVGs; XML escape through real render pipeline; `formatInt` cases; `TestDonutSingleSlice` (guards the empty-arc regression); `TestDonutEmpty` (no-data fallback).
- `internal/card/card_test.go` — `RenderAll` produces 15 valid SVGs; XML escape through real render pipeline; `formatInt` cases; `TestDonutSingleSlice` / `TestDonutEmpty` (donut edge cases); `TestCardsFitFrame` (renders every card against an adversarial profile and asserts text + coordinates stay in the 340×200 frame); `TestFitTitleFontSize` (pins the auto-shrink table for every real title); `TestNiceTicksCoversMax` (guards the `yMax ≥ dataMax` invariant so bars can't overflow chartH).
- `internal/github/profile_test.go` — `sortLangStats` ordering and tiebreak.
- `main_test.go` — `TestUTCOffsetLabel` covers UTC, Asia/Saigon, half-hour (Kolkata), quarter-hour (Kathmandu) zones.
+46 -12
View File
@@ -11,7 +11,7 @@ Visual conventions for ghstats SVG cards. All cards share a single frame shape s
| Stroke | `theme.Stroke` at `theme.StrokeOpacity` |
| Fill | `theme.Background` |
| Font family | `'Segoe UI', Ubuntu, Sans-Serif` |
| Title | 15 px, weight 600, `theme.Title`, anchored at `(20, 30)` |
| Title | weight 600, `theme.Title`, anchored at `(20, 30)`. Font size auto-shrinks from 15 px down to 11 px when the string wouldn't fit in `width − 20 − 4` at 0.6 × fontSize char-width (see `fitTitleFontSize` in `svg.go`). 40-char titles like `Commits by Weekday (last year, UTC+7.00)` land at 13 px. |
Generated by `header(width, height, bg, stroke, strokeOpacity, titleColor, title)` in `internal/card/svg.go`.
@@ -61,18 +61,51 @@ Language colors come from linguist via GraphQL (`repo.languages.edges[].node.col
When there's **exactly one slice** (one language at 100%), the renderer emits two concentric `<circle>` elements instead of a pie arc, because SVG's `A` command from point P back to the same P draws nothing. Regression guarded by `TestDonutSingleSlice`.
## Bar-chart cards (productive time)
## Bar-chart cards (productive time, productive weekday, contributions-by-year)
| Metric | Value |
| --- | --- |
| Chart area | `x ∈ [35, 325]`, `y ∈ [45, 155]` (110 tall) |
| Bars | 24 bars, 1 px gap |
| Bar fill | `theme.Accent` |
| Y-axis ticks | `niceTicks(max, 5)` — 1/2/5 × 10^k ladder |
| X-axis labels | Hours 0, 6, 12, 18, 23 |
| Axis caption | "hour of day" bottom-center |
| Title format | `Commits by Hour (<window>, UTC±N.NN)` |
| Hover | `<title>HH:00 — N commits</title>` inside each bar |
| Bars | `productive-time`: 24 bars, 1 px gap · `productive-weekday`: 7 bars, 6 px gap · `contributions-by-year`: N bars (1 per active year) |
| Bar fill | `theme.Accent` for the peak bar; `mixHex(Background, Accent, 0.55)` dim for the rest so the busiest period reads at a glance |
| Y-axis ticks | `niceTicks(max, 5)` — 1/2/5 × 10^k ladder. `last = ceil(max/step) × step` so `yMax ≥ dataMax` always (bars can't poke above chartH into the title) |
| Axis caption | "hour of day" bottom-center on productive-time; weekday / by-year omit the caption since the x labels are self-describing |
| Title format | `Commits by Hour (<window>, UTC±N.NN)` / `Commits by Weekday (<window>, UTC±N.NN)` / `Contributions by Year` |
| Hover | `<title>HH:00 — N commits</title>` / `<title>Mon — N commits</title>` / `<title>YYYY — N commits</title>` |
## Heatmap card (contributions-heatmap)
| Metric | Value |
| --- | --- |
| Grid | 7 rows × 53 columns (Sunday → Saturday, oldest week → newest) |
| Cell size | 5 × 5 px, 1 px gap (exact fit: `leftPad(22) + 53 × 6 = 340`) |
| Cell colour | 5-bucket ramp `mixHex(Background, Accent, k/4)` for `k ∈ 0..4` — no dedicated ramp field on the theme schema |
| Weekday labels | Mon / Wed / Fri only, right-anchored in the `leftPad` gutter |
| Month labels | Printed above the first week where a 1st-of-month day falls; skipped when `x > width − 20` so `Dec` / `Apr` can't spill past the frame |
| Legend | "Less ▢▢▢▢▢ More" bottom-right |
| Hover | `<title>YYYY-MM-DD — N</title>` per cell |
## Stat-column cards (streak)
Three big-number columns (`340 / 3 ≈ 113 px` each) sharing one layout:
| Metric | Value |
| --- | --- |
| Column centres | 56, 169, 282 |
| Big number | 28 px weight 700, `theme.Accent`, `text-anchor="middle"` at y=95. Always a single `formatInt` integer so the column can't overflow |
| Label | 12 px, `theme.Text`, middle-anchored at y=120 |
| Detail line | 10 px, `theme.Muted`, middle-anchored at y=140 — streak date range (`Jan 2 — Dec 31` same year, `2024 — 2026` across years) or `of N total (P%)` for active days |
## List cards (top-starred-repos)
Rows of `language swatch + repo name + proportional bar + star count`:
| Metric | Value |
| --- | --- |
| Row spacing | 22 px, first row y=60 |
| Name | 12 px, `theme.Text`, `truncate(..., 17)` so a 40-char repo name doesn't overflow |
| Bar | `x ∈ [150, 270]` (120 px wide), 10 px tall, `theme.Accent` foreground over 15 % ghost track |
| Star count | 12 px weight 600, right-anchored at x=334, suffix `★` — no separate icon (the title `Top Starred Repos` already establishes context) |
## Area-chart cards (contributions)
@@ -113,7 +146,7 @@ Concrete rules this implies:
- **Reserve columns.** When a card has `N` equal-width columns, treat `340 / N` as the hard limit for each column's widest element. No centered text can be wider than its column.
- **Right-anchored values** (stats rows, top-starred bars) must leave a safety margin. Right edge ≤ `width − 6`; do not place an icon to the right of a right-anchored number (they collide on multi-digit values).
- **Long strings get truncated, not wrapped.** Use `truncateName` (top-starred) or pick a layout that allows clipping via `text-overflow` semantics. Never let a wide string push a later element off-screen.
- **Long strings get truncated, not wrapped.** Use the rune-aware `truncate()` helper in `svg.go` — it's how `profile-details`, `top-starred-repos`, and `cardTitle` clamp to their column budgets. Never let a wide string push a later element off-screen.
- **Variable-count grids** (heatmap 7×N, by-year N bars) must compute cell size from the container width, not the other way round. Don't hardcode a cell size that only works for the author's profile.
- **Month / year tick labels** within `~20 px` of the right edge must be skipped (they read past the frame otherwise).
@@ -142,5 +175,6 @@ The `demo/<theme>/` gallery auto-regenerates on every push to `main`; use the la
## Text overflow
- Long strings (bio, repo names) are **not truncated**; they're XML-escaped and printed as-is.
- If a card looks crowded at 340 px width, that's a card design problem — fix the layout, not the data.
- Long strings (bio, repo names, company, location, website) are **rune-truncated** by `truncate(s, n)` in `svg.go` (appends `…` after `n-1` runes). Profile rows clamp at 40, profile title at 34, top-starred repo names at 17.
- Y-axis tick labels are routed through `formatTick`, which abbreviates ≥ 1000 to `k` / `M` / `B` so no label exceeds 4 chars (`1500 → "1.5k"`, `12345 → "12k"`, `1234567 → "1.2M"`). Keeps the left gutter ≤ 28 px even for busy profiles.
- If a card still looks crowded at 340 px width, that's a card design problem — fix the layout, not silently drop data.
+1 -1
View File
@@ -77,7 +77,7 @@ Card count: 9 → 15 (weekday adds LY + AT variants). `FetchProductive` still pa
## Phase 7.5 — Demo gallery for theme discovery (✅ done)
- New `.github/workflows/demo.yml` renders every card for every theme against the repo owner's profile on each push to `main`.
- Output lands in `demo/<theme>/`, with an auto-generated `demo/README.md` TOC so reviewers can browse palettes side-by-side with real data instead of cloning and running the CLI.
- Output lands in `demo/<theme>/` (SVGs + a `README.md` for that theme pairing last-year / all-time variants side-by-side); top-level `demo/README.md` is a zero-image index linking to each theme page, so opening the gallery doesn't force a reader to fetch 975 SVGs at once.
- Loop prevention: workflow skips pushes that only touch `demo/**`, `**.md`, or `LICENSE`; `GITHUB_TOKEN`-driven pushes don't retrigger workflows by design.
- Consumer impact: none — this is a repo-internal discovery aid, not a shipped feature.
+8 -1
View File
@@ -97,10 +97,17 @@ Each card produces a self-contained SVG with:
- Content layer (chart elements, text, legend)
Shared primitives:
- `renderDonutCard(title, stats, theme)` — pie slices via polar arc math + legend with color swatches. Single-slice case (one language at 100%) renders as two concentric `<circle>` elements instead of an arc, since SVG's `A` command from point P back to P draws nothing.
- `renderDonutCard(title, stats, theme)` — pie slices via polar arc math + legend with color swatches (top 7 entries, rest collapse into "Other"). Single-slice case (one language at 100%) renders as two concentric `<circle>` elements instead of an arc, since SVG's `A` command from point P back to P draws nothing.
- `renderProductiveTime(title, hours, theme)` — 24 bars + both axes + tick math from `niceTicks`
- `renderWeekday(title, data, theme)` — 7-bar day-of-week chart mirroring the productive-time layout; peak bar uses `theme.Accent`, others `mixHex(Background, Accent, 0.55)`
- `renderHeatmap(title, days, theme)` — 7×53 calendar grid with a 5-bucket intensity ramp synthesised from `theme.Background → theme.Accent`
- `renderContributions(title, days, theme)` — monthly aggregation, Catmull-Rom → cubic Bezier area path, two-sided Y axis
Chart-geometry invariants:
- `niceTicks(max, 5)` rounds the top tick up to the next step (`last = ceil(max/step) × step`), guaranteeing `yMax ≥ dataMax` — bar heights can never exceed `chartH` and collide with the title row.
- `formatTick` abbreviates ≥ 1000 to `k` / `M` / `B` so y-axis labels never exceed 4 characters (`10000 → "10k"`, `1234567 → "1.2M"`); keeps the left gutter ≤ 28 px for every profile.
- `header()` picks the largest title font in [11, 15] px at which the string fits in `width − 24` at a 0.6 char-width estimate, so long titles like `Commits by Weekday (last year, UTC+7.00)` still fit the frame.
Catmull-Rom control-point math: for each segment `P_i → P_{i+1}`,
```
C1 = P_i + (P_{i+1} - P_{i-1}) / 6