# 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 `` 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 (, UTC±H[:MM])` / `Commits by Weekday ()` — weekday drops the UTC so the title always fits at 15 px / `Contributions by Year` | | Hover | `HH:00 — N commits` / `Mon — N commits` / `YYYY — N commits` | | Bar order | `productive-weekday`: position 0 = `Profile.WeekStart` (set via `-start-of-week`, default Sunday); remaining bars rotate forward `(WeekStart+i) % 7`. Peak highlight tracks the drawn position so it stays aligned with the visible tallest bar. | ## 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 | Every other row (positions 1, 3, 5), right-anchored in the `leftPad` gutter. Label text is `weekdayShort[(int(WeekStart)+i) % 7]`, so Sunday-start renders Mon/Wed/Fri; Monday-start renders Tue/Thu/Sat. | | Row order | Row 0 = `Profile.WeekStart` (`-start-of-week` flag; default `time.Sunday` matches GitHub's own calendar). `padToWeekGrid` rotates the leading blank pad to match. | | 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 | `YYYY-MM-DD — N` 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 `` strings in `internal/card/icons.go`. - Rendered inside `` (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 `` 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 ``, ``, ``, ``, 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//` 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 (``) 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.