From aa2f8cb48ed50c7eb92131c026402d8b889806c6 Mon Sep 17 00:00:00 2001 From: Faizan Yousaf Date: Wed, 4 Nov 2026 11:58:13 +0500 Subject: [PATCH 1/2] feat: add odometer style animation to star count --- resources/css/app.css | 36 ++++++ .../js/components/commit-star-button.tsx | 23 +--- resources/js/components/odometer-count.tsx | 122 ++++++++++++++++++ tests/Unit/CommitStarButton.test.tsx | 15 ++- 4 files changed, 172 insertions(+), 24 deletions(-) create mode 100644 resources/js/components/odometer-count.tsx diff --git a/resources/css/app.css b/resources/css/app.css index d8de19c393..d4fa57d5de 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -272,6 +272,42 @@ both; --animate-star-pop: star-pop 450ms cubic-bezier(0.175, 0.885, 0.32, 1.275) both; + --animate-odometer-up-out: odometer-up-out 250ms + cubic-bezier(0.16, 1, 0.3, 1) both; + --animate-odometer-up-in: odometer-up-in 250ms cubic-bezier(0.16, 1, 0.3, 1) + both; + --animate-odometer-down-out: odometer-down-out 250ms + cubic-bezier(0.16, 1, 0.3, 1) both; + --animate-odometer-down-in: odometer-down-in 250ms + cubic-bezier(0.16, 1, 0.3, 1) both; + + @keyframes odometer-up-out { + to { + transform: translateY(-100%); + opacity: 0; + } + } + + @keyframes odometer-up-in { + from { + transform: translateY(100%); + opacity: 0; + } + } + + @keyframes odometer-down-out { + to { + transform: translateY(100%); + opacity: 0; + } + } + + @keyframes odometer-down-in { + from { + transform: translateY(-100%); + opacity: 0; + } + } --animate-flame-flicker: flame-flicker 5s ease-in-out infinite; @keyframes flame-flicker { diff --git a/resources/js/components/commit-star-button.tsx b/resources/js/components/commit-star-button.tsx index 3f80fa66dd..2467f38449 100644 --- a/resources/js/components/commit-star-button.tsx +++ b/resources/js/components/commit-star-button.tsx @@ -6,6 +6,7 @@ import CommitStarController from '@/actions/App/Http/Controllers/CommitStarContr import { ActionTooltip } from '@/components/action-tooltip'; import { CommitStarPollingContext } from '@/components/commit-star-polling'; import { CommitStargazersDialog } from '@/components/commit-stargazers-dialog'; +import { OdometerCount } from '@/components/odometer-count'; import { StarredRemovalContext } from '@/components/starred-removal-context'; import { useCommitNotFound } from '@/hooks/use-deleted-resources'; import { useOwnsCommit } from '@/hooks/use-owns-commit'; @@ -13,7 +14,7 @@ import { refreshSharedState, useSharedState } from '@/hooks/use-shared-state'; import { signIn } from '@/lib/auth'; import { createCommitStarMutations } from '@/lib/commit-star-mutations'; import { commitStateKey } from '@/lib/commit-state'; -import { formatCount, formatExactCount } from '@/lib/numbers'; +import { formatExactCount } from '@/lib/numbers'; import { toastWhenThrottled } from '@/lib/throttle'; import { cn } from '@/lib/utils'; import type { Commit } from '@/types'; @@ -172,7 +173,7 @@ export function CommitStarButton({ aria-keyshortcuts={keyShortcut} aria-label={starLabel(starred, count)} className={cn( - 'group/star inline-flex items-center gap-1 rounded-md px-1.5 py-1 font-mono text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground', + 'group/star inline-flex items-center gap-1 rounded-md px-1.5 py-1 font-mono text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground has-[[data-test=star-count]:empty]:mr-[calc(0.25rem+1ch)]', starred && 'text-primary hover:text-primary', className, )} @@ -186,14 +187,7 @@ export function CommitStarButton({ isAnimating && 'motion-safe:animate-star-pop', )} /> - {count > 0 && ( - - {formatCount(count)} - - )} + ); @@ -235,14 +229,7 @@ function StarCount({ data-test="own-star-count" > - {count > 0 && ( - - {formatCount(count)} - - )} + diff --git a/resources/js/components/odometer-count.tsx b/resources/js/components/odometer-count.tsx new file mode 100644 index 0000000000..bc53cedb8f --- /dev/null +++ b/resources/js/components/odometer-count.tsx @@ -0,0 +1,122 @@ +import { useLayoutEffect, useRef, useState } from 'react'; +import { formatCount } from '@/lib/numbers'; +import { cn } from '@/lib/utils'; + +const ANIMATION_MS = 250; + +interface OdometerCountProps { + value: number; + className?: string; + emptyWhenZero?: boolean; +} + +function displayedCount(value: number, emptyWhenZero: boolean): string { + return emptyWhenZero && value === 0 ? '' : formatCount(value); +} + +export function OdometerCount({ + value, + className, + emptyWhenZero = false, +}: OdometerCountProps) { + const previousValueRef = useRef(value); + const [previous, setPrevious] = useState(() => + displayedCount(value, emptyWhenZero), + ); + const [isAnimating, setIsAnimating] = useState(false); + const [direction, setDirection] = useState<'up' | 'down'>('up'); + + useLayoutEffect(() => { + if (value === previousValueRef.current) { + return; + } + + const oldValue = previousValueRef.current; + previousValueRef.current = value; + setPrevious(displayedCount(oldValue, emptyWhenZero)); + setDirection(value >= oldValue ? 'up' : 'down'); + setIsAnimating(true); + + const timer = setTimeout(() => setIsAnimating(false), ANIMATION_MS); + + return () => clearTimeout(timer); + }, [value, emptyWhenZero]); + + const current = displayedCount(value, emptyWhenZero); + const length = Math.max(current.length, previous.length); + const currentChars = current.padStart(length, ' '); + const previousChars = previous.padStart(length, ' '); + + return ( + + {isAnimating + ? Array.from({ length }, (_, index) => { + const char = currentChars[index]; + const previousChar = previousChars[index]; + const place = length - index; + const isDigit = char >= '0' && char <= '9'; + const previousIsDigit = + previousChar >= '0' && previousChar <= '9'; + + if ( + char === previousChar || + (!isDigit && !previousIsDigit) || + (isDigit && + !previousIsDigit && + previousChar !== ' ') || + (!isDigit && previousIsDigit && char !== ' ') + ) { + return ( + + {char} + + ); + } + + return ( + + {previousChar !== ' ' && ( + + )} + {char !== ' ' ? ( + + {char} + + ) : ( + + {previousChar} + + )} + + ); + }) + : current} + + ); +} diff --git a/tests/Unit/CommitStarButton.test.tsx b/tests/Unit/CommitStarButton.test.tsx index 55796d0c4b..0f68fcc33b 100644 --- a/tests/Unit/CommitStarButton.test.tsx +++ b/tests/Unit/CommitStarButton.test.tsx @@ -72,7 +72,7 @@ for (const status of ['approved', 'pending', 'rejected'] as const) { assert.match(body, /disabled=""/); assert.match( body, - /data-test="own-star-count"[^>]*>]*>.*?<\/svg><\/button>/, + /data-test="own-star-count"[^>]*>]*>.*?<\/svg>.*?<\/button>/, ); }); @@ -87,19 +87,22 @@ for (const user of [null, { id: 'viewer' }]) { }); } - test('unstarred commits show only the star in the star control', async () => { + test('unstarred commits reserve the count slot outside the star control', async () => { const body = await render({ id: 'viewer' }, { stars_count: 0 }); - assert.match( - body, - /data-test="star-commit-button"[^>]*>]*>.*?<\/svg><\/button>/, + assert.ok( + body.includes( + 'has-[[data-test=star-count]:empty]:mr-[calc(0.25rem+1ch)]', + ), ); + assert.ok(body.includes('empty:hidden')); + assert.match(body, /data-test="star-count"[^>]*><\/span>/); }); test('starred commits keep the count slot inside the star control', async () => { const body = await render({ id: 'viewer' }, { stars_count: 1 }); - assert.doesNotMatch(body, /mr-\[calc/); + assert.doesNotMatch(body, / mr-\[calc/); assert.match(body, /data-test="star-count"[^>]*>1<\/span>/); }); From 9b270a00fb392f7cc79addfb2d40ca9ef8287fda Mon Sep 17 00:00:00 2001 From: Faizan Yousaf Date: Wed, 4 Nov 2026 12:02:57 +0500 Subject: [PATCH 2/2] test: cover odometer digit transitions --- resources/js/components/odometer-count.tsx | 23 +++------- resources/js/lib/odometer.ts | 48 +++++++++++++++++++++ tests/Unit/Odometer.test.ts | 50 ++++++++++++++++++++++ 3 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 resources/js/lib/odometer.ts create mode 100644 tests/Unit/Odometer.test.ts diff --git a/resources/js/components/odometer-count.tsx b/resources/js/components/odometer-count.tsx index bc53cedb8f..39e2456499 100644 --- a/resources/js/components/odometer-count.tsx +++ b/resources/js/components/odometer-count.tsx @@ -1,5 +1,6 @@ import { useLayoutEffect, useRef, useState } from 'react'; import { formatCount } from '@/lib/numbers'; +import { odometerPlaces } from '@/lib/odometer'; import { cn } from '@/lib/utils'; const ANIMATION_MS = 250; @@ -43,9 +44,7 @@ export function OdometerCount({ }, [value, emptyWhenZero]); const current = displayedCount(value, emptyWhenZero); - const length = Math.max(current.length, previous.length); - const currentChars = current.padStart(length, ' '); - const previousChars = previous.padStart(length, ' '); + const places = odometerPlaces(previous, current); return ( {isAnimating - ? Array.from({ length }, (_, index) => { - const char = currentChars[index]; - const previousChar = previousChars[index]; - const place = length - index; - const isDigit = char >= '0' && char <= '9'; - const previousIsDigit = - previousChar >= '0' && previousChar <= '9'; + ? places.map(({ char, previousChar, isRolling }, index) => { + const place = places.length - index; - if ( - char === previousChar || - (!isDigit && !previousIsDigit) || - (isDigit && - !previousIsDigit && - previousChar !== ' ') || - (!isDigit && previousIsDigit && char !== ' ') - ) { + if (!isRolling) { return ( {char} diff --git a/resources/js/lib/odometer.ts b/resources/js/lib/odometer.ts new file mode 100644 index 0000000000..1625665d9d --- /dev/null +++ b/resources/js/lib/odometer.ts @@ -0,0 +1,48 @@ +export interface OdometerPlace { + /** The character shown at this place now. */ + char: string; + /** The character it replaces, or a space when the count grows into this place. */ + previousChar: string; + /** Whether this place rolls from the previous character. */ + isRolling: boolean; +} + +function isDigit(char: string): boolean { + return char >= '0' && char <= '9'; +} + +function rolls(char: string, previousChar: string): boolean { + if (char === previousChar) { + return false; + } + + if (!isDigit(char) && !isDigit(previousChar)) { + return false; + } + + if (isDigit(char) && isDigit(previousChar)) { + return true; + } + + return isDigit(char) ? previousChar === ' ' : char === ' '; +} + +/** + * The places of the new count, paired with the character each replaces, so the view rolls only what changed. A digit + * that appears or disappears rolls; a separator or a shortened count's suffix changes in place. + */ +export function odometerPlaces( + previous: string, + current: string, +): OdometerPlace[] { + const length = Math.max(previous.length, current.length); + const currentChars = current.padStart(length, ' '); + const previousChars = previous.padStart(length, ' '); + + return Array.from({ length }, (_, index) => { + const char = currentChars[index]; + const previousChar = previousChars[index]; + + return { char, previousChar, isRolling: rolls(char, previousChar) }; + }); +} diff --git a/tests/Unit/Odometer.test.ts b/tests/Unit/Odometer.test.ts new file mode 100644 index 0000000000..cfdea2dec7 --- /dev/null +++ b/tests/Unit/Odometer.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { odometerPlaces } from '../../resources/js/lib/odometer.ts'; + +// A space is a place the count does not have; `·` makes it visible in the expectations. +const shown = (previous: string, current: string): string[] => + odometerPlaces(previous, current).map( + ({ char, previousChar, isRolling }) => { + const place = (value: string) => (value === ' ' ? '·' : value); + + return isRolling + ? `${place(previousChar)}→${place(char)}` + : place(char); + }, + ); + +void test('leaves every place still when the count does not change', () => { + assert.deepEqual(shown('1,250', '1,250'), ['1', ',', '2', '5', '0']); +}); + +void test('rolls only the digit that changes', () => { + assert.deepEqual(shown('120', '121'), ['1', '2', '0→1']); + assert.deepEqual(shown('121', '120'), ['1', '2', '1→0']); +}); + +void test('rolls a new place in when the count grows', () => { + assert.deepEqual(shown('9', '10'), ['·→1', '9→0']); + assert.deepEqual(shown('99', '100'), ['·→1', '9→0', '9→0']); +}); + +void test('rolls the old place out when the count shrinks', () => { + assert.deepEqual(shown('10', '9'), ['1→·', '0→9']); + assert.deepEqual(shown('100', '99'), ['1→·', '0→9', '0→9']); +}); + +void test('leaves the thousands separator still while the digits roll', () => { + assert.deepEqual(shown('999', '1,000'), ['·→1', ',', '9→0', '9→0', '9→0']); +}); + +void test("leaves a shortened count's suffix still while the digits roll", () => { + // Covers both directions: the comma and `k` stay still; a digit replacing `k` also stays still. + assert.deepEqual(shown('9,999', '10k'), ['9→·', '·', '9→1', '9→0', 'k']); + assert.deepEqual(shown('10k', '9,999'), ['·→9', ',', '1→9', '0→9', '9']); +}); + +void test('rolls the first digit in and out when the count appears or disappears', () => { + assert.deepEqual(shown('', '1'), ['·→1']); + assert.deepEqual(shown('1', ''), ['1→·']); + assert.deepEqual(shown('', ''), []); +});