mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-09-01 16:18:09 +00:00
## Heatmap — two halves, 8x8 cells The single-row 53-week layout could never hold square cells larger than 4x4 inside a 340 px card — cramped. Split the year in half (ceil(weeks/2) on top, floor on bottom) and each half is ~27 weeks wide, freeing the cells to be 8x8 (4x the area) while keeping comfortable left (30 px) and right (~67 px) gutters. Grid: topPadA = 45, half = 7*9 - 1 = 62 tall halfGap = 13 topPadB = 120, same 62 tall grid bottom at y = 182, 18 px frame margin Year still reads top-to-bottom left-to-right, just with one extra line break at the midpoint. Dropped the separate Less/More legend — at 8x8 the intensity gradient is self-explanatory, and the removal buys the vertical space the new layout needs. Refactored into a helper `renderHeatmapHalf` so the two halves share a single code path (labels + month markers + cells). ## Font-size vocabulary Four named constants in svg.go: fontBody=12, fontLabel=11, fontAxis=10, fontBigNum=28. Every card's existing literals already sit on this ladder except the heatmap, which was using 9 for weekday/month labels. Heatmap now routes through fontAxis so the whole gallery shares one size scale. Other cards' literals weren't rewritten to reference the constants (pure churn for no behavior change); the constants give future cards the vocabulary and the design-guidelines the schema.
182 lines
11 KiB
Markdown
182 lines
11 KiB
Markdown
# Design Guidelines
|
||
|
||
Visual conventions for ghstats SVG cards. All cards share a single frame shape so they stack cleanly in a README — two cards sit side-by-side inside GitHub's ~816 px content column.
|
||
|
||
## Card frame
|
||
|
||
| Property | Value |
|
||
| --- | --- |
|
||
| Width × Height | **340 × 200** (matches github-profile-summary-cards) |
|
||
| Corner radius | 6 px |
|
||
| Stroke | `theme.Stroke` at `theme.StrokeOpacity` |
|
||
| Fill | `theme.Background` |
|
||
| Font family | `'Segoe UI', Ubuntu, Sans-Serif` |
|
||
| 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`.
|
||
|
||
## Theme role mapping
|
||
|
||
| Theme field | Used for |
|
||
| --- | --- |
|
||
| `Title` | Card title text |
|
||
| `Text` | Primary content (values, names) |
|
||
| `Background` | Card fill + stroke around donut slices to separate colors |
|
||
| `Stroke` + `StrokeOpacity` | Card outline |
|
||
| `Muted` | Axis lines, axis labels, icons, legend metadata |
|
||
| `Accent` | Bars, area fills, stat values, fallback slice color |
|
||
|
||
Cards MUST NOT hardcode colors outside these fields. If a new visual needs a shade, pick the closest existing field — don't extend the schema without a strong reason.
|
||
|
||
## Row-based cards (profile, stats)
|
||
|
||
Single-column rows of `icon + label` or `icon + label + value`.
|
||
|
||
| Metric | Profile | Stats |
|
||
| --- | --- | --- |
|
||
| First row baseline (y) | 60 | 55 |
|
||
| Row spacing | 20 px | 20 px |
|
||
| Row x padding | 20 | 20 |
|
||
| Icon scale | `12/16 = 0.75` from 16×16 Octicon viewBox | same |
|
||
| Icon color | `theme.Muted` | same |
|
||
| Value font | 12 px, `theme.Text` | 12 px, weight 600, `theme.Accent`, right-anchored at `x = 320` |
|
||
|
||
Cap rows at what fits: up to 7 rows per card. Stats splits commits into lifetime + last-year rows.
|
||
|
||
## Donut cards (language breakdowns)
|
||
|
||
| Metric | Value |
|
||
| --- | --- |
|
||
| Donut centre | `(250, 110)` |
|
||
| Outer radius | 55 |
|
||
| Inner radius | 30 |
|
||
| Top-N entries shown | 7 rows max, "Other" inclusive (6 named + "Other" when there's a tail, up to 7 named when there isn't) |
|
||
| Slice stroke | `theme.Background`, 1.5 px (gap between slices) |
|
||
| Legend origin | `(20, 55)` |
|
||
| Legend row height | 20 px |
|
||
| Swatch size | 10 × 10 |
|
||
| Legend font | 11 px |
|
||
|
||
Language colors come from linguist via GraphQL (`repo.languages.edges[].node.color`). Missing colors fall back to `theme.Accent`.
|
||
|
||
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, productive weekday, contributions-by-year)
|
||
|
||
| Metric | Value |
|
||
| --- | --- |
|
||
| Chart area | `x ∈ [35, 325]`, `y ∈ [45, 155]` (110 tall) |
|
||
| 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 |
|
||
| --- | --- |
|
||
| Layout | **Two stacked halves** of ~27 weeks each. One-row 53-week layout forces cells down to 4 × 4 to fit in 340 px; splitting in half lets each half be 27 weeks wide at **8 × 8 square cells** — 4× the cell area. Year still reads top-to-bottom, left-to-right. |
|
||
| Cell size | 8 × 8 px square, 1 px gap |
|
||
| Grid geometry | `leftPad 30`, `topPadA 45` (top half), `halfGap 13`, `topPadB 120`. Each half is 7 × 9 − 1 = 62 px tall. Grid bottom at y=182 leaves 18 px for the frame border. |
|
||
| 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)
|
||
|
||
| Metric | Value |
|
||
| --- | --- |
|
||
| Chart area | `x ∈ [28, 312]`, `y ∈ [45, 150]` (105 tall) |
|
||
| Curve | Catmull-Rom → cubic Bezier (tension 0.5) |
|
||
| Fill | `theme.Accent` at 25% opacity |
|
||
| Stroke | `theme.Accent`, 2 px |
|
||
| Y-axis | **Both sides**, mirrored, same tick values |
|
||
| X-axis labels | `mm/yy`, stride-thinned to ~6 labels regardless of bucket count |
|
||
| Axis caption | "mm/yy" bottom-center |
|
||
| First/last labels | Always printed (pinned endpoints) |
|
||
|
||
Missing months in the `[first, last]` range are inserted as zero-count rows to keep the curve time-continuous.
|
||
|
||
## Icons
|
||
|
||
- Sourced from [Primer Octicons](https://primer.style/octicons/) 16×16 set.
|
||
- Stored as raw `<path d="…"/>` strings in `internal/card/icons.go`.
|
||
- Rendered inside `<g transform="translate(x,y) scale(0.75)" fill="muted">…</g>` (scaled to fit the 12 px box).
|
||
- Used: `iconRepos`, `iconCompany`, `iconLocation`, `iconClock`, `iconLink`, `iconPeople`, `iconStar`, `iconCommit`, `iconPR`, `iconIssue`, `iconReview`.
|
||
|
||
Add new icons by copying the `<path>` from Octicons and appending to `icons.go`. Keep them to the same 16×16 viewBox so the existing scale math applies.
|
||
|
||
## Fit-the-frame invariant (MUST hold before release)
|
||
|
||
Every card MUST render entirely inside the 340 × 200 frame **for every profile the card can encounter**, not just the author's. That means:
|
||
|
||
| Thing that varies | Worst case to design for |
|
||
| --- | --- |
|
||
| Star counts, commit counts, streak lengths, active days | 10-digit formatted integer (e.g. `1,234,567,890`) |
|
||
| Repo / language / company / location names | 40+ char strings with CJK / emoji |
|
||
| Number of active years | 20+ years (contribution history can start in 2008) |
|
||
| Contribution calendar weeks | 53 — not 52 — when the window spans a year transition |
|
||
|
||
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 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).
|
||
|
||
### Review checklist (before merging any card change)
|
||
|
||
Render the **dracula** theme against at least three profiles or synthetic fixtures:
|
||
|
||
1. **Tiny**: a brand-new account with 0 commits, 0 stars, 1 repo.
|
||
2. **Typical**: the author's profile (`tiennm99` via `demo/` regeneration).
|
||
3. **Adversarial**: seven-digit commit counts, 40-char repo / company / location names, 20 active years, 53-week span. A small synthetic `*.json` fixture is fine; it doesn't need a token.
|
||
|
||
Then open every affected SVG at 1× and 2× zoom and verify:
|
||
|
||
- [ ] No `<text>`, `<rect>`, `<circle>`, `<line>`, or path coordinate exceeds `x=340` or `y=200`, or falls below `x=0` / `y=0`.
|
||
- [ ] No two elements overlap in a way that makes either unreadable.
|
||
- [ ] Right-anchored numbers don't collide with icons, swatches, or bars.
|
||
- [ ] Peak-vs-dim highlighting still reads at a glance (dracula `Background → Accent` contrast is fine; light themes like `github` or `nord_bright` need a separate check).
|
||
|
||
The `demo/<theme>/` gallery auto-regenerates on every push to `main`; use the last CI run as the dracula reference, and stress-test the adversarial case locally before pushing.
|
||
|
||
## Accessibility
|
||
|
||
- Contrast is the theme author's responsibility — we don't validate at runtime.
|
||
- Tooltips (`<title>`) on productive-time bars let screen readers announce counts.
|
||
- No motion, no `<animate>` elements — profile READMEs render statically.
|
||
|
||
## Text overflow
|
||
|
||
- 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.
|