mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-09-01 08:22:29 +00:00
Three cards overflowed the 340×200 frame for realistic profile data: - contributions-heatmap: the classic case — 53 weeks at 9px cellSize + 2px gap pushed the grid out to x≈611. Shrink to cellSize=5, cellGap=1 so leftPad(22) + 53*6 = 340 (exact fit). Drop month labels within 20 px of the right edge so "Dec"/"Apr" can't stick past the frame. - streak: the third column rendered "N / M" at font-size 28, centered at x=282. For 4+ digit totals (e.g. 584 / 3031) the text extended to x≈347. Refactor to show the active-days integer by itself in the big slot and push "of N total (P%)" into the small detail line that the other two columns already use. - top-starred-repos: the per-row star icon sat at x=306 while the right- anchored number ended at x=334, so 5+ digit star counts collided with the icon. Drop the icon (card title already says "Top Starred Repos"), emit the count as "N ★", right-anchor at x=334 with a 6 px safety gap. Add a new TestCardsFitFrame stress test that renders every card against an adversarial profile (10-digit counts, 40-char names, 20 active years, 53-week span) and asserts every positional attribute stays inside the frame. This is the automated half of the new "fit-the-frame invariant" added to docs/design-guidelines.md + a pre-release review checklist in docs/code-standards.md. Bug reports will still surface text-overflow cases that the coordinate check can't see (a text-anchor="middle" element has a single x attribute but renders outward), so the docs also spell out the human-review step: render dracula against tiny/typical/adversarial fixtures before release.
147 lines
7.3 KiB
Markdown
147 lines
7.3 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 | 15 px, weight 600, `theme.Title`, anchored at `(20, 30)` |
|
||
|
||
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 | 5 (overflow collapses into "Other") |
|
||
| 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)
|
||
|
||
| 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 |
|
||
|
||
## 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 `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.
|
||
- **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) 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.
|