Commit Graph
FreeA contribution heatmap paged by two-week sprints, drillable to the day.
Commit activity
9165 commits in sprints 22 to 26
Open a sprint for its own heat map
npx shadcn@latest add @ui-shrushank/commit-graphThe install writes a single file to components/ui/CommitGraph.tsx, which exports CommitGraph.
import { CommitGraph } from "@/components/ui/CommitGraph";<CommitGraph />Needs framer-motion and @phosphor-icons/react installed. The shadcn CLI adds them for you.
A GitHub-style contribution heatmap, cut into the unit teams actually review against.
- Five swappable colour themes (swatch picker included), a documented exception to the two-hue rule
- A streak badge that only shows once there's an actual streak
- Clicking Commit round-trips through a simulated ~500ms backend call before today's cell levels up (the site is a static export with no server), and the returned count persists to
localStoragethe way a server response would be cached, so it survives a reload - A production version swaps the mock for a real POST. The 10-week run is grouped as 5 two-week sprints (
SPRINT_WEEKSxSPRINTS, withWEEKSderived), each carrying its own total, and each sprint group is a real<button>that opens a detail panel for that sprint alone.
The simulated latency + localStorage persistence makes the demo behave like a real server-backed feature, and the note documents exactly what the production swap is.
Every line below was read out of the file you are about to copy, so you can check each one against the source at the bottom of this page.
- Uses the native
role="group",role="radiogroup",role="radio"semantics rather than a styleddiv, so assistive technology announces what the control is and how it behaves. - Carries
aria-checked,aria-label,aria-pressed, so its state is exposed and not left to the visual treatment alone. - Has an explicit
prefers-reduced-motionbranch: the animation is dropped for an instant state change, and the component stays fully usable without it. - Handles keys directly, so every pointer gesture it supports has a keyboard equivalent.
- Draws a
focus-visiblering that is separate from its hover treatment, so keyboard users get an affordance mouse users do not take away.
- A sprint click is a navigation, not an expansion.
- Five totals answer "which sprint was heavy"; they can't answer "what happened inside it". The overview is replaced by that sprint's own heat map: the same fourteen days redrawn as a 7x2 calendar at 44px with the exact count in every cell, weekday headers and W1/W2 row labels.
- The commit log sits under the sprint's calendar.
- Commits are grouped by day with a time and a short SHA, and any day cell filters the log to itself. A count tells you Tuesday was busy; the log tells you what Tuesday was.
- The number inside a cell needs two colours, not one.
- The ramp runs dark-on-light in light mode and light-on-dark in dark mode.
- Commit is hidden inside past sprints.
- It lands on today, and an action that changes a number the open view can't show is worse than no action.
- Focus follows the navigation both ways.
- Escape or "All sprints" comes back out to the sprint you opened.
- The log's row count is the sprint total by construction.
- Both read the same
seededCount, so the headline can never drift from the list under it. - History is 26 sprints deep with five on screen.
- Chevrons either side of the window page back a year, and a second pair inside a sprint steps to its neighbours. The window follows you, so coming back out of Sprint 3 lands on the page holding Sprint 3 rather than back at the newest.
- The subtitle counts the window, not the year.
- "98 commits in sprints 17 to 21", for the same reason the log row count matches the sprint total: a headline should count what is actually on screen. Keeping a year costs nothing because
seededCountis a pure function of grid position, so the 21 sprints behind the window are the same arithmetic as the five in it.
One file, no runtime package. Copy it, or install it with the command at the top of this page.
"use client";import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";import { motion, useReducedMotion } from "framer-motion";import { GitCommitIcon as PhGitCommit, CircleNotchIcon as PhCircleNotch, FlameIcon as PhFlame, ArrowCounterClockwiseIcon as PhReset, ArrowLeftIcon as PhArrowLeft, CaretLeftIcon as PhCaretLeft, CaretRightIcon as PhCaretRight,} from "@phosphor-icons/react";const SPRINT_WEEKS = 2;// A year of two-week sprints, five of them on screen at a time. seededCount is// a pure function of the grid position, so history costs nothing to keep: the// 21 sprints behind the window are the same arithmetic as the 5 in it.const SPRINT_HISTORY = 26;const VISIBLE_SPRINTS = 5;const WEEKS = SPRINT_HISTORY * SPRINT_WEEKS;const DAYS = 7;const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];const EXTRA_KEY = "ui-commit-graph:extra";const THEME_KEY = "ui-commit-graph:theme";type ThemeId = "emerald" | "blue" | "violet" | "amber" | "rose";interface Theme { label: string; swatch: string; light: [string, string, string, string]; dark: [string, string, string, string];}const THEMES: Record<ThemeId, Theme> = { emerald: { label: "Emerald", swatch: "#16A34A", light: ["#BBF7D0", "#4ADE80", "#16A34A", "#166534"], dark: ["#14532D", "#15803D", "#22C55E", "#86EFAC"] }, blue: { label: "Blue", swatch: "#2563EB", light: ["#BFDBFE", "#60A5FA", "#2563EB", "#1E3A8A"], dark: ["#1E3A8A", "#1D4ED8", "#3B82F6", "#93C5FD"] }, violet: { label: "Violet", swatch: "#7C3AED", light: ["#DDD6FE", "#A78BFA", "#7C3AED", "#4C1D95"], dark: ["#4C1D95", "#6D28D9", "#8B5CF6", "#C4B5FD"] }, amber: { label: "Amber", swatch: "#D97706", light: ["#FDE68A", "#FBBF24", "#D97706", "#92400E"], dark: ["#78350F", "#B45309", "#F59E0B", "#FCD34D"] }, rose: { label: "Rose", swatch: "#DB2777", light: ["#FBCFE8", "#F472B6", "#DB2777", "#831843"], dark: ["#831843", "#BE185D", "#EC4899", "#F9A8D4"] },};const THEME_IDS = Object.keys(THEMES) as ThemeId[];// Pure function of grid position, not wall-clock time: every cell but the very// last one is deterministic across server and client renders, so there's// nothing here for hydration to disagree about.function seededCount(week: number, day: number): number { const n = week * 7 + day + 11; const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; const frac = x - Math.floor(x); if (frac > 0.94) return 8 + Math.floor(frac * 37) % 6; if (frac > 0.8) return 4 + Math.floor(frac * 23) % 4; if (frac > 0.5) return 1 + Math.floor(frac * 17) % 3; return 0;}// Integer hash (Math.imul, exact in every engine), deliberately not the// sin-based seededCount above. A bucketed level survives a 1-ULP difference// between two engines' Math.sin; a message index or a SHA does not, and text// that differs between the server and the client is a hydration error.function hash32(seed: number): number { let h = Math.imul(seed ^ 0x9e3779b9, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16; return h >>> 0;}// A real deploy reads these from the git log (or the host's API). They are// generated here for the same reason the counts are: the site is a static// export, so the demo has to carry its own history.const SUBJECTS = [ "fix: guard the tick generator against an empty range", "feat: add orientation to the chart frame", "refactor: pull the tooltip anchor out of the crosshair", "fix: stop the axis rounding away a fifth of the plot", "chore: bump framer-motion to 11.3.2", "test: cover the quantile legend ticks", "perf: batch the dot layer into one path", "fix: round projected coords so SSR and the client agree", "docs: note why the preview files are generated", "feat: keyboard roving across the heat map cells", "fix: dark ramp was two steps too light at level 3", "refactor: hoist the shared spline into the frame", "chore: regenerate the preview chunks", "fix: legend toggle left the y domain in dead space", "feat: sticky first column on the data table", "style: align the tick labels to the baseline", "fix: reset the day filter when the sprint changes", "test: snapshot the empty state", "perf: memoize the grid so hover stops re-rendering it", "fix: focus ring vanished at the light end of the ramp", "feat: copy button on the code block", "refactor: drop the wrapper div around every cell", "chore: pin the node version in CI", "fix: tooltip clipped inside the scroll container", "docs: rewrite the install steps for bun", "feat: reduced-motion path for the reveal", "fix: aria-label read the count without the day", "revert: put the old easing back on the drawer", "chore: delete the unused ramp helper", "fix: off-by-one in the week boundary",];interface Commit { sha: string; time: string; subject: string;}// Every stride is coprime with SUBJECTS.length, so walking the pool by one of// them visits every subject before repeating: a day never lands the same commit// message twice, which is what gives a hashed message away as fake.const STRIDES = [7, 11, 13, 17, 19, 23];function commitsFor(dayIndex: number, count: number): Commit[] { const base = hash32(dayIndex * 31 + 7) % SUBJECTS.length; const stride = STRIDES[hash32(dayIndex * 17 + 5) % STRIDES.length]; return Array.from({ length: count }, (_, i) => { const h = hash32(dayIndex * 97 + i * 7919 + 3); return { h, minutes: 8 * 60 + (h % 660) }; }) .sort((a, b) => a.minutes - b.minutes) .map(({ h, minutes }, i) => ({ sha: h.toString(16).padStart(8, "0").slice(0, 7), time: `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`, subject: SUBJECTS[(base + i * stride) % SUBJECTS.length], }));}function levelFor(count: number): 0 | 1 | 2 | 3 | 4 { if (count <= 0) return 0; if (count <= 2) return 1; if (count <= 5) return 2; if (count <= 9) return 3; return 4;}const subscribeNever = () => () => {};// Read through useSyncExternalStore rather than seeding useState from// localStorage in an effect: the server snapshot (0 / "emerald") is what both// the server and the first client paint render, so there's nothing for// hydration to disagree about, and no cascading setState-in-effect render.function getExtraSnapshot(): number { try { const stored = Number(localStorage.getItem(EXTRA_KEY) ?? 0); return Number.isFinite(stored) && stored > 0 ? stored : 0; } catch { return 0; }}function getExtraServerSnapshot() { return 0;}function getThemeSnapshot(): ThemeId { try { const stored = localStorage.getItem(THEME_KEY) as ThemeId | null; return stored && stored in THEMES ? stored : "emerald"; } catch { return "emerald"; }}function getThemeServerSnapshot(): ThemeId { return "emerald";}function GitCommitIcon() { return <PhGitCommit size={13} weight="bold" aria-hidden />;}function SpinnerIcon() { return <PhCircleNotch size={13} weight="bold" aria-hidden />;}function FlameIcon() { return <PhFlame size={11} weight="fill" aria-hidden />;}function ResetIcon() { return <PhReset size={11} aria-hidden />;}function BackIcon() { return <PhArrowLeft size={12} weight="bold" aria-hidden />;}function OlderIcon() { return <PhCaretLeft size={12} weight="bold" aria-hidden />;}function NewerIcon() { return <PhCaretRight size={12} weight="bold" aria-hidden />;}export function CommitGraph() { const shouldReduceMotion = useReducedMotion(); const persistedExtra = useSyncExternalStore(subscribeNever, getExtraSnapshot, getExtraServerSnapshot); const persistedTheme = useSyncExternalStore(subscribeNever, getThemeSnapshot, getThemeServerSnapshot); const [extraOverride, setExtraOverride] = useState<number | null>(null); const [themeOverride, setThemeOverride] = useState<ThemeId | null>(null); const [committing, setCommitting] = useState(false); const [justCommitted, setJustCommitted] = useState(false); const [selected, setSelected] = useState<number | null>(null); const [selectedDay, setSelectedDay] = useState<number | null>(null); const [windowStart, setWindowStart] = useState(SPRINT_HISTORY - VISIBLE_SPRINTS); const [pageDir, setPageDir] = useState(0); const sprintRefs = useRef<(HTMLButtonElement | null)[]>([]); const backRef = useRef<HTMLButtonElement | null>(null); const returnFocusTo = useRef<number | null>(null); const wasSelected = useRef<number | null>(null); const extra = extraOverride ?? persistedExtra; const theme = themeOverride ?? persistedTheme; const weeks = useMemo(() => { const grid: number[][] = []; for (let w = 0; w < WEEKS; w++) { const week: number[] = []; for (let d = 0; d < DAYS; d++) { const base = seededCount(w, d); const isToday = w === WEEKS - 1 && d === DAYS - 1; week.push(isToday ? base + extra : base); } grid.push(week); } return grid; }, [extra]); const sprints = useMemo( () => Array.from({ length: SPRINT_HISTORY }, (_, s) => { const cols = weeks.slice(s * SPRINT_WEEKS, (s + 1) * SPRINT_WEEKS); const days = cols.flat(); return { cols, days, total: days.reduce((sum, c) => sum + c, 0), activeDays: days.filter((c) => c > 0).length, best: Math.max(...days), }; }), [weeks], ); // The window's own total, not the year's: the headline should count what is // actually on screen. const visibleSprints = sprints.slice(windowStart, windowStart + VISIBLE_SPRINTS); const windowTotal = visibleSprints.reduce((sum, sprint) => sum + sprint.total, 0); const atOldest = windowStart === 0; const atNewest = windowStart >= SPRINT_HISTORY - VISIBLE_SPRINTS; const streak = useMemo(() => { const flat = weeks.flat(); let count = 0; for (let i = flat.length - 1; i >= 0; i--) { if (flat[i] > 0) count++; else break; } return count; }, [weeks]); const detail = selected === null ? null : sprints[selected]; const isCurrentSprint = selected === SPRINT_HISTORY - 1; const delta = selected === null || selected === 0 ? null : sprints[selected].total - sprints[selected - 1].total; // Every day of the open sprint, its commits attached. Days keep their slot // even when empty so the heat map and the log agree on what day 4 is. const sprintLog = useMemo(() => { if (detail === null || selected === null) return []; return detail.days.map((count, idx) => { const week = Math.floor(idx / DAYS); const weekday = idx % DAYS; const absoluteDay = (selected * SPRINT_WEEKS + week) * DAYS + weekday; return { week, weekday, count, commits: commitsFor(absoluteDay, count) }; }); }, [detail, selected]); const visibleDays = selectedDay === null ? sprintLog.filter((day) => day.count > 0) : [sprintLog[selectedDay]]; // Focus follows the view, in both directions: into the sprint it lands on the // way back out, and on return it lands on the sprint you came from rather // than at the top of the card. useEffect(() => { const was = wasSelected.current; wasSelected.current = selected; if (selected !== null && was === null) { backRef.current?.focus({ preventScroll: true }); } else if (selected === null && was !== null && returnFocusTo.current !== null) { sprintRefs.current[returnFocusTo.current]?.focus({ preventScroll: true }); returnFocusTo.current = null; } }, [selected]); // Stepping to a sprint also drags the overview window along, so coming back // out lands on the sprint you were reading rather than where you started. const openSprint = (si: number) => { const target = Math.min(Math.max(si, 0), SPRINT_HISTORY - 1); returnFocusTo.current = target; setSelected(target); setSelectedDay(null); setWindowStart((start) => Math.min(Math.max(start, target - VISIBLE_SPRINTS + 1), target)); }; const page = (direction: -1 | 1) => { setPageDir(direction); setWindowStart((start) => Math.min(Math.max(start + direction * VISIBLE_SPRINTS, 0), SPRINT_HISTORY - VISIBLE_SPRINTS)); }; const closeDetail = () => { setSelected(null); setSelectedDay(null); }; const activeTheme = THEMES[theme]; const ramp = (level: 0 | 1 | 2 | 3 | 4, isDark: boolean) => (level === 0 ? null : (isDark ? activeTheme.dark : activeTheme.light)[level - 1]); const changeTheme = (id: ThemeId) => { setThemeOverride(id); try { localStorage.setItem(THEME_KEY, id); } catch { // no-op } }; const handleCommit = () => { if (committing) return; setCommitting(true); // A real deploy of this component would POST to /api/commits and read the // updated count back; this demo simulates that round trip's latency and // persists the result the same place a server response would end up // caching to, so the count survives a reload without a real backend. const latency = 420 + Math.round(Math.random() * 260); window.setTimeout(() => { const next = extra + 1; try { localStorage.setItem(EXTRA_KEY, String(next)); } catch { // no-op } setExtraOverride(next); setCommitting(false); setJustCommitted(true); window.setTimeout(() => setJustCommitted(false), 500); }, latency); }; const handleReset = () => { setExtraOverride(0); try { localStorage.removeItem(EXTRA_KEY); } catch { // no-op } }; // Commit lands on today, which only exists in the current sprint. Rather than // mutate a number the open view can't show, the action is only offered where // its effect is visible. // Same rule for the paged-away overview as for a past sprint: today has to be // on screen for the button that changes it to be offered. const showCommitAction = selected === null ? atNewest : isCurrentSprint; const enter = shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }; return ( <div onKeyDown={(event) => { if (event.key === "Escape" && selected !== null) closeDetail(); }} className="w-full max-w-md rounded-2xl border border-stone-200 bg-white p-4 dark:border-stone-800 dark:bg-stone-900" > <div className="flex items-start justify-between gap-3"> {detail === null || selected === null ? ( <div> <div className="flex items-center gap-1.5"> <p className="text-sm font-semibold text-stone-800 dark:text-stone-100">Commit activity</p> {streak > 0 && ( <motion.span initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} className="inline-flex items-center gap-0.5 rounded-full bg-orange-500/10 px-1.5 py-0.5 text-[11px] font-semibold text-orange-600 dark:text-orange-400" > <FlameIcon /> {streak} </motion.span> )} </div> <p className="text-xs text-stone-500 dark:text-stone-500"> {windowTotal} commits in sprints {windowStart + 1} to {windowStart + VISIBLE_SPRINTS} </p> <p className="text-[11px] text-stone-500 dark:text-stone-500">Open a sprint for its own heat map</p> </div> ) : ( <div> <div className="flex items-center gap-1.5"> <button ref={backRef} type="button" onClick={closeDetail} className="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-medium text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400 dark:text-stone-400 dark:hover:bg-white/10 dark:hover:text-stone-200" > <BackIcon /> All sprints </button> </div> <div className="mt-1 flex items-center gap-1.5"> <button type="button" onClick={() => openSprint(selected - 1)} disabled={selected === 0} aria-label={`Sprint ${selected}, the sprint before this one`} title={selected === 0 ? "No older sprint" : `Sprint ${selected}`} className="inline-flex h-5 w-5 items-center justify-center rounded-full text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400 disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-stone-300" > <OlderIcon /> </button> <p className="text-sm font-semibold text-stone-800 dark:text-stone-100">Sprint {selected + 1}</p> <button type="button" onClick={() => openSprint(selected + 1)} disabled={isCurrentSprint} aria-label={`Sprint ${selected + 2}, the sprint after this one`} title={isCurrentSprint ? "No newer sprint" : `Sprint ${selected + 2}`} className="inline-flex h-5 w-5 items-center justify-center rounded-full text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400 disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-stone-300" > <NewerIcon /> </button> {isCurrentSprint && ( <span className="rounded-full bg-stone-900/[0.06] px-1.5 py-0.5 text-[11px] font-medium uppercase tracking-[0.08em] text-stone-500 dark:bg-white/10 dark:text-stone-500"> In progress </span> )} </div> <p className="text-xs text-stone-500 dark:text-stone-500"> {detail.total} commits, {detail.activeDays} of {SPRINT_WEEKS * DAYS} days active, busiest {detail.best} {delta !== null && ( <span className={delta > 0 ? "text-green-600 dark:text-green-400" : undefined}> {", "} {delta > 0 ? "+" : ""} {delta} vs S{selected} </span> )} </p> </div> )} {showCommitAction && ( <div className="flex items-center gap-1"> {extra > 0 && ( <button type="button" onClick={handleReset} aria-label="Reset simulated commits" title="Reset simulated commits" className="inline-flex h-7 w-7 items-center justify-center rounded-full text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400 dark:hover:bg-white/10 dark:hover:text-stone-300" > <ResetIcon /> </button> )} <button type="button" onClick={handleCommit} disabled={committing} className="inline-flex items-center gap-1.5 rounded-full bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400 disabled:pointer-events-none disabled:opacity-50" > {committing ? ( <motion.span animate={{ rotate: 360 }} transition={{ duration: 0.7, repeat: Infinity, ease: "linear" }}> <SpinnerIcon /> </motion.span> ) : ( <GitCommitIcon /> )} {committing ? "Committing…" : "Commit"} </button> </div> )} </div> {detail === null || selected === null ? ( <motion.div key="overview" initial={enter} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}> <div className="mt-4 flex items-center gap-1"> <button type="button" onClick={() => page(-1)} disabled={atOldest} aria-label="Show older sprints" title="Older sprints" className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400 disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-stone-300" > <OlderIcon /> </button> <motion.div key={windowStart} initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, x: pageDir * 14 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }} className="flex-1 overflow-x-auto pb-1" data-lenis-prevent > <div className="mx-auto flex w-max gap-1" role="group" aria-label={`Sprints ${windowStart + 1} to ${windowStart + VISIBLE_SPRINTS}`}> {visibleSprints.map((sprint, wi) => { const si = windowStart + wi; const isCurrent = si === SPRINT_HISTORY - 1; return ( <button key={si} type="button" ref={(node) => { sprintRefs.current[si] = node; }} onClick={() => openSprint(si)} aria-label={`Sprint ${si + 1}${isCurrent ? " (current)" : ""}, ${sprint.total} commits. Open its heat map.`} className="flex flex-col rounded-lg p-1 transition-colors hover:bg-stone-900/[0.03] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400 dark:hover:bg-white/[0.04]" > <div className="flex gap-[3px]"> {sprint.cols.map((week, ci) => { const wi = si * SPRINT_WEEKS + ci; return ( <div key={ci} className="flex flex-col gap-[3px]"> {week.map((count, di) => { const level = levelFor(count); const isToday = wi === WEEKS - 1 && di === DAYS - 1; return ( <motion.div key={di} aria-hidden title={`${isToday ? "Today, " : ""}week ${ci + 1} ${WEEKDAYS[di]}: ${count} commit${count === 1 ? "" : "s"}`} initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.4 }} animate={ isToday && justCommitted ? { opacity: 1, scale: [1, 1.5, 1] } : { opacity: 1, scale: 1 } } transition={ isToday && justCommitted ? { duration: 0.45, ease: [0.16, 1, 0.3, 1] } : { duration: 0.25, delay: Math.min(wi * DAYS + di, 24) * 0.008, ease: [0.16, 1, 0.3, 1] } } className={`relative h-[11px] w-[11px] overflow-hidden rounded-[2.5px] ${isToday ? "ring-1 ring-emerald-500/70 ring-offset-1 ring-offset-white dark:ring-offset-stone-950" : ""}`} > <span className="block h-full w-full rounded-[2.5px] dark:hidden" style={{ backgroundColor: ramp(level, false) ?? "rgb(226 232 240)" }} /> <span className="hidden h-full w-full rounded-[2.5px] dark:block" style={{ backgroundColor: ramp(level, true) ?? "rgb(255 255 255 / 0.08)" }} /> </motion.div> ); })} </div> ); })} </div> <span className="mt-2 flex flex-col items-center leading-tight"> <span className={`text-[11px] uppercase tracking-[0.08em] ${ isCurrent ? "text-stone-500 dark:text-stone-400" : "text-stone-400 dark:text-stone-600" }`} > S{si + 1} </span> <span className={`text-[11px] tabular-nums ${ isCurrent ? "font-semibold text-stone-700 dark:text-stone-200" : "text-stone-500 dark:text-stone-500" }`} > {sprint.total} </span> </span> </button> ); })} </div> </motion.div> <button type="button" onClick={() => page(1)} disabled={atNewest} aria-label="Show newer sprints" title="Newer sprints" className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400 disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-stone-300" > <NewerIcon /> </button> </div> </motion.div> ) : ( <motion.div key={`sprint-${selected}`} initial={enter} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }} > <div className="mt-4"> <div className="flex gap-2 pl-6" aria-hidden> {WEEKDAYS.map((day) => ( <span key={day} className="flex-1 text-center text-[11px] text-stone-500 dark:text-stone-500"> {day.slice(0, 3)} </span> ))} </div> {detail.cols.map((week, ci) => ( <div key={ci} className="mt-2 flex items-center gap-2"> <span className="w-4 shrink-0 text-[11px] uppercase tracking-[0.08em] text-stone-500 dark:text-stone-500" aria-hidden> W{ci + 1} </span> {week.map((count, di) => { const idx = ci * DAYS + di; const level = levelFor(count); const isDaySelected = selectedDay === idx; const isToday = isCurrentSprint && ci === SPRINT_WEEKS - 1 && di === DAYS - 1; const dayLabel = `Week ${ci + 1} ${WEEKDAYS[di]}, ${count} commit${count === 1 ? "" : "s"}`; return ( <motion.button key={di} type="button" onClick={() => setSelectedDay(isDaySelected ? null : idx)} aria-pressed={isDaySelected} aria-label={`${isToday ? "Today. " : ""}${dayLabel}. Show this day's commits.`} initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.24, delay: idx * 0.012, ease: [0.16, 1, 0.3, 1] }} className={`relative flex h-11 flex-1 items-center justify-center overflow-hidden rounded-lg text-xs font-semibold tabular-nums transition-transform hover:scale-[1.04] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400 ${ isDaySelected ? "ring-2 ring-stone-900/40 ring-offset-2 ring-offset-white dark:ring-white/50 dark:ring-offset-stone-950" : isToday ? "ring-1 ring-emerald-500/70 ring-offset-1 ring-offset-white dark:ring-offset-stone-950" : "" }`} > <span className="absolute inset-0 block dark:hidden" style={{ backgroundColor: ramp(level, false) ?? "rgb(226 232 240)" }} /> <span className="absolute inset-0 hidden dark:block" style={{ backgroundColor: ramp(level, true) ?? "rgb(255 255 255 / 0.08)" }} /> {/* The ramp runs dark-on-light one way and light-on-dark the other, so the number needs its own pair, not one colour. */} <span className={`relative block dark:hidden ${level >= 3 ? "text-white" : "text-stone-700"}`}> {count > 0 ? count : ""} </span> <span className={`relative hidden dark:block ${level >= 3 ? "text-stone-900" : "text-stone-100"}`}> {count > 0 ? count : ""} </span> </motion.button> ); })} </div> ))} </div> <div className="mt-4 border-t border-stone-200/70 pt-2 dark:border-white/10"> <div className="flex items-center justify-between gap-2"> <p className="text-[11px] uppercase tracking-[0.08em] text-stone-500 dark:text-stone-500"> {selectedDay === null ? "Commits, day by day" : `Week ${sprintLog[selectedDay].week + 1} ${WEEKDAYS[sprintLog[selectedDay].weekday]}, ${sprintLog[selectedDay].count} commit${sprintLog[selectedDay].count === 1 ? "" : "s"}`} </p> {selectedDay !== null && ( <button type="button" onClick={() => setSelectedDay(null)} className="rounded-full px-1.5 py-0.5 text-[11px] text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400 dark:text-stone-400 dark:hover:bg-white/10 dark:hover:text-stone-200" > All days </button> )} </div> <div className="mt-1 max-h-44 overflow-y-auto pr-1" data-lenis-prevent> {visibleDays.every((day) => day.count === 0) ? ( <p className="py-3 text-center text-[11px] text-stone-500 dark:text-stone-500">No commits on this day.</p> ) : ( <ul className="space-y-2"> {visibleDays.map((day) => ( <li key={`${day.week}-${day.weekday}`}> {selectedDay === null && ( <div className="flex items-baseline justify-between gap-2"> <p className="text-[11px] font-medium uppercase tracking-[0.08em] text-stone-500 dark:text-stone-500"> W{day.week + 1} {WEEKDAYS[day.weekday]} </p> <p className="text-[11px] tabular-nums text-stone-400 dark:text-stone-600"> {day.count} commit{day.count === 1 ? "" : "s"} </p> </div> )} <ul className="mt-1 space-y-1"> {day.commits.map((commit, ci) => ( <li key={`${commit.sha}-${ci}`} className="flex items-baseline gap-2 text-[11px]"> <span className="tabular-nums text-stone-400 dark:text-stone-600">{commit.time}</span> <span className="font-mono text-[11px] text-stone-500 dark:text-stone-500">{commit.sha}</span> <span className="min-w-0 flex-1 truncate text-stone-600 dark:text-stone-300" title={commit.subject}> {commit.subject} </span> </li> ))} </ul> </li> ))} </ul> )} </div> </div> </motion.div> )} <div className="mt-3 flex items-center justify-between"> <div className="flex items-center gap-1.5" role="radiogroup" aria-label="Color theme"> {THEME_IDS.map((id) => ( <button key={id} type="button" role="radio" aria-checked={theme === id} aria-label={THEMES[id].label} title={THEMES[id].label} onClick={() => changeTheme(id)} className={`h-4 w-4 shrink-0 rounded-full transition-[color,background-color,border-color,transform,opacity] duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400 ${ theme === id ? "ring-2 ring-offset-2 ring-offset-white dark:ring-offset-stone-950" : "opacity-60 hover:opacity-100" }`} style={{ backgroundColor: THEMES[id].swatch, ...(theme === id ? ({ "--tw-ring-color": THEMES[id].swatch } as CSSProperties) : {}) }} /> ))} </div> <div className="flex items-center gap-1 text-[11px] text-stone-500 dark:text-stone-500"> Less {([0, 1, 2, 3, 4] as const).map((level) => ( <span key={level} aria-hidden className="h-[9px] w-[9px] rounded-[2px] dark:hidden" style={{ backgroundColor: ramp(level, false) ?? "rgb(226 232 240)" }} /> ))} {([0, 1, 2, 3, 4] as const).map((level) => ( <span key={level} aria-hidden className="hidden h-[9px] w-[9px] rounded-[2px] dark:block" style={{ backgroundColor: ramp(level, true) ?? "rgb(255 255 255 / 0.08)" }} /> ))} More </div> </div> </div> );}PressCtrl+Cto copy