Files
ghstats/docs/design-guidelines.md
T
tiennm99andGitHub 70bcbd43f2 feat(card): stack heatmap into two halves; unify font-size vocabulary (#23)
## 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.
2026-04-19 11:39:50 +07:00

182 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.