mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-11 12:18:55 +00:00
docs: resync after card fixes — title auto-fit, truncate, abbreviated ticks (#15)
Several recent code changes hadn't propagated to the docs:
- design-guidelines
* Card frame title row: document the 11–15 px auto-shrink (not a flat
15 px anymore).
* Donut Top-N: already 7 (updated earlier).
* Bar-chart section renamed to cover weekday + by-year too; document
the peak-vs-dim highlight convention and the niceTicks yMax ≥ max
invariant.
* Add Heatmap / Stat-column (streak) / List (top-starred) card
sections — they were missing entirely.
* Rewrite "Text overflow" from "we don't truncate" to the current
truth (truncate helper, formatTick abbreviations).
* Replace dangling `truncateName` reference with `truncate`.
- code-standards
* SVG output standards: call out truncate, formatTick abbreviation,
header auto-fit so the card-review gate reflects what the renderers
actually do.
- codebase-summary
* Layout tree: svg.go / axis.go comments list the helpers they now
contain; productive.go notes the weekday histogram.
* demo/ tree shows the index vs per-theme split.
* Data-flow diagram includes Weekday in the productive pass.
* Test coverage row lists TestCardsFitFrame / TestFitTitleFontSize /
TestNiceTicksCoversMax — the new invariant guards.
- system-architecture
* Shared primitives list adds renderWeekday and renderHeatmap; donut
blurb updated to "top 7".
* New "Chart-geometry invariants" block documents niceTicks ceiling,
formatTick abbreviation, header auto-fit.
- project-roadmap
* Phase 7.5 bullet updated to describe the index + per-theme demo
split (was single-README TOC).
This commit is contained in:
1 parent
fc29729aa2
commit
88397e80a3
5 files changed
+67
-22
No files matched your search
@@ -57,8 +57,10 @@ Never write comments that describe what well-named code does (`// increment coun
|
||||
## SVG output standards
|
||||
|
||||
- Always XML-escape user-controlled strings through `escapeXML` (`&`, `<`, `>`, `"`, `'`).
|
||||
- Numbers formatted via `formatInt` with thousands separators.
|
||||
- Long user-controlled strings (bio, names, company, repo slugs) go through `truncate` in `svg.go` before rendering — never print raw multi-line input that could push a later element off-screen.
|
||||
- Body numbers formatted via `formatInt` (thousands separators). Y-axis tick numbers go through `formatTick`, which abbreviates ≥ 1000 to `k` / `M` / `B` so no label exceeds 4 chars.
|
||||
- Stable viewbox per card (`340×200`) matching github-profile-summary-cards.
|
||||
- Titles render through `header()` which auto-shrinks font-size (15 → 11 px) when the string wouldn't fit in `width − 20 − 4`.
|
||||
- No `<script>` tags, no event handlers. Cards are pure markup.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -15,13 +15,13 @@ ghstats/
|
||||
│ │ ├── queries.go # profileQuery, commitHistoryQuery, contributionYearQuery
|
||||
│ │ ├── model.go # Profile, RepoInfo, LangStat, LangEdge, DailyContribution
|
||||
│ │ ├── profile.go # FetchProfile — user + owned repos + stats + calendar
|
||||
│ │ ├── productive.go # FetchProductive — commit history → hour histogram + lang buckets
|
||||
│ │ ├── productive.go # FetchProductive — commit history → hour + weekday histograms + lang buckets
|
||||
│ │ ├── contributions_all_time.go # FetchContributionsAllTime — per-year loop → seed list + daily series
|
||||
│ │ └── profile_test.go # sortLangStats tiebreak
|
||||
│ ├── card/ # SVG renderers; one file per card
|
||||
│ │ ├── card.go # Card interface, RenderAll, allCards slice
|
||||
│ │ ├── svg.go # escapeXML, formatInt, header, footer
|
||||
│ │ ├── axis.go # niceTicks (d3-style 1/2/5 × 10^k), formatTick
|
||||
│ │ ├── svg.go # escapeXML, formatInt, truncate, header (auto-fit title), footer
|
||||
│ │ ├── axis.go # niceTicks (d3-style, last tick ≥ max), formatTick (1500→"1.5k")
|
||||
│ │ ├── icons.go # Octicon path strings
|
||||
│ │ ├── profile.go # profile-details
|
||||
│ │ ├── repos_per_language.go # repos-per-language
|
||||
@@ -45,8 +45,10 @@ ghstats/
|
||||
│ └── demo.yml # Renders every theme for the repo owner on push to main
|
||||
├── docs/ # This directory
|
||||
├── plans/ # Research reports + implementation plans
|
||||
└── demo/ # Auto-generated gallery — every card × every theme + README
|
||||
# (`output/` is entirely gitignored; see demo/ for reference renders)
|
||||
└── demo/ # Auto-generated gallery
|
||||
├── README.md # Lightweight index (links only, zero images)
|
||||
└── <theme>/ # Per-theme page: 15 SVGs + README pairing LY / AT variants
|
||||
# (`output/` is entirely gitignored; see demo/ for reference renders)
|
||||
```
|
||||
|
||||
## Module responsibilities
|
||||
@@ -99,7 +101,7 @@ contributionYearQuery ─┬──► SeedRepos + DailyContributionsAllTime + To
|
||||
│
|
||||
└─ seed into ─►
|
||||
│
|
||||
commitHistoryQuery ──► Productive + CommitsByLanguage (+ AllTime variants)
|
||||
commitHistoryQuery ──► Productive + Weekday + CommitsByLanguage (+ AllTime variants)
|
||||
│
|
||||
▼
|
||||
15 SVG files per theme
|
||||
@@ -107,7 +109,7 @@ commitHistoryQuery ──► Productive + CommitsByLanguage (+ AllTime variants)
|
||||
|
||||
## Test coverage
|
||||
|
||||
- `internal/card/card_test.go` — `RenderAll` produces 15 valid SVGs; XML escape through real render pipeline; `formatInt` cases; `TestDonutSingleSlice` (guards the empty-arc regression); `TestDonutEmpty` (no-data fallback).
|
||||
- `internal/card/card_test.go` — `RenderAll` produces 15 valid SVGs; XML escape through real render pipeline; `formatInt` cases; `TestDonutSingleSlice` / `TestDonutEmpty` (donut edge cases); `TestCardsFitFrame` (renders every card against an adversarial profile and asserts text + coordinates stay in the 340×200 frame); `TestFitTitleFontSize` (pins the auto-shrink table for every real title); `TestNiceTicksCoversMax` (guards the `yMax ≥ dataMax` invariant so bars can't overflow chartH).
|
||||
- `internal/github/profile_test.go` — `sortLangStats` ordering and tiebreak.
|
||||
- `main_test.go` — `TestUTCOffsetLabel` covers UTC, Asia/Saigon, half-hour (Kolkata), quarter-hour (Kathmandu) zones.
|
||||
|
||||
|
||||
+46
-12
@@ -11,7 +11,7 @@ Visual conventions for ghstats SVG cards. All cards share a single frame shape s
|
||||
| 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)` |
|
||||
| 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`.
|
||||
|
||||
@@ -61,18 +61,51 @@ Language colors come from linguist via GraphQL (`repo.languages.edges[].node.col
|
||||
|
||||
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)
|
||||
## Bar-chart cards (productive time, productive weekday, contributions-by-year)
|
||||
|
||||
| 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 |
|
||||
| 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 | 5 × 5 px, 1 px gap (exact fit: `leftPad(22) + 53 × 6 = 340`) |
|
||||
| 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)
|
||||
|
||||
@@ -113,7 +146,7 @@ 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.
|
||||
- **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).
|
||||
|
||||
@@ -142,5 +175,6 @@ The `demo/<theme>/` gallery auto-regenerates on every push to `main`; use the la
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
@@ -77,7 +77,7 @@ Card count: 9 → 15 (weekday adds LY + AT variants). `FetchProductive` still pa
|
||||
## Phase 7.5 — Demo gallery for theme discovery (✅ done)
|
||||
|
||||
- New `.github/workflows/demo.yml` renders every card for every theme against the repo owner's profile on each push to `main`.
|
||||
- Output lands in `demo/<theme>/`, with an auto-generated `demo/README.md` TOC so reviewers can browse palettes side-by-side with real data instead of cloning and running the CLI.
|
||||
- Output lands in `demo/<theme>/` (SVGs + a `README.md` for that theme pairing last-year / all-time variants side-by-side); top-level `demo/README.md` is a zero-image index linking to each theme page, so opening the gallery doesn't force a reader to fetch 975 SVGs at once.
|
||||
- Loop prevention: workflow skips pushes that only touch `demo/**`, `**.md`, or `LICENSE`; `GITHUB_TOKEN`-driven pushes don't retrigger workflows by design.
|
||||
- Consumer impact: none — this is a repo-internal discovery aid, not a shipped feature.
|
||||
|
||||
|
||||
@@ -97,10 +97,17 @@ Each card produces a self-contained SVG with:
|
||||
- Content layer (chart elements, text, legend)
|
||||
|
||||
Shared primitives:
|
||||
- `renderDonutCard(title, stats, theme)` — pie slices via polar arc math + legend with color swatches. Single-slice case (one language at 100%) renders as two concentric `<circle>` elements instead of an arc, since SVG's `A` command from point P back to P draws nothing.
|
||||
- `renderDonutCard(title, stats, theme)` — pie slices via polar arc math + legend with color swatches (top 7 entries, rest collapse into "Other"). Single-slice case (one language at 100%) renders as two concentric `<circle>` elements instead of an arc, since SVG's `A` command from point P back to P draws nothing.
|
||||
- `renderProductiveTime(title, hours, theme)` — 24 bars + both axes + tick math from `niceTicks`
|
||||
- `renderWeekday(title, data, theme)` — 7-bar day-of-week chart mirroring the productive-time layout; peak bar uses `theme.Accent`, others `mixHex(Background, Accent, 0.55)`
|
||||
- `renderHeatmap(title, days, theme)` — 7×53 calendar grid with a 5-bucket intensity ramp synthesised from `theme.Background → theme.Accent`
|
||||
- `renderContributions(title, days, theme)` — monthly aggregation, Catmull-Rom → cubic Bezier area path, two-sided Y axis
|
||||
|
||||
Chart-geometry invariants:
|
||||
- `niceTicks(max, 5)` rounds the top tick up to the next step (`last = ceil(max/step) × step`), guaranteeing `yMax ≥ dataMax` — bar heights can never exceed `chartH` and collide with the title row.
|
||||
- `formatTick` abbreviates ≥ 1000 to `k` / `M` / `B` so y-axis labels never exceed 4 characters (`10000 → "10k"`, `1234567 → "1.2M"`); keeps the left gutter ≤ 28 px for every profile.
|
||||
- `header()` picks the largest title font in [11, 15] px at which the string fits in `width − 24` at a 0.6 char-width estimate, so long titles like `Commits by Weekday (last year, UTC+7.00)` still fit the frame.
|
||||
|
||||
Catmull-Rom control-point math: for each segment `P_i → P_{i+1}`,
|
||||
```
|
||||
C1 = P_i + (P_{i+1} - P_{i-1}) / 6
|
||||
|
||||
Reference in new issue
Block a user