Files
ghstats/docs/design-guidelines.md
T
tiennm99 5cda59ae79 fix(card): heatmap cells back to 4x4 squares with side gutters (#22)
The 4x12 rectangular stretch from v1.2.3 read as "weird". Requirement
is square cells + comfortable side padding. Given the 53-week hard
constraint, those two requirements together pick the cell size:

  53 * (size + gap) + leftPad + rightPad = 340

Candidates audited:
  6 x 6, gap 1 → 371 wide, overflows the frame
  5 x 5, gap 1 → 318 wide, only 11 px of total side padding ("very close")
  5 x 5, gap 0 → 265 wide, cells touch (need a stroke to fake a gap)
  4 x 4, gap 1 → 265 wide, 30 + 45 px gutters, real 1 px gaps ✓
  3 x 3, gap 2 → 265 wide, cells become pinhead-sized

4 x 4 with a 1 px gap is the largest square that keeps breathing room
on both sides without any rendering trickery. Grid is 35 px tall;
there's leftover vertical space on the card but short beats "stretched
horizontal bands" visually. Bump topPad from 62 → 70 to offset the
grid slightly down from the title and reduce that apparent emptiness.

Legend swatches also revert to the same cellSize so the Less/More
bar matches the grid visually again.
2026-04-19 11:23:48 +07:00

11 KiB
Raw Blame History

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
Grid 7 rows × 53 columns (Sunday → Saturday, oldest week → newest)
Cell size 4 × 4 px square, 1 px gap. 4 is the largest square that fits with breathing room on both sides (leftPad 30 + 53 × 5 = 295 px, 45 px right gutter). 5 × 5 with a gap overflows; 5 × 5 touching cells loses visible separation; rectangular cells look stretched. Card has vertical headroom to spare — we accept that in exchange for a clean square grid
Grid y-range topPad(70) .. 70 + 7 × 5 = 105 px
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 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.