diff --git a/CLAUDE.md b/CLAUDE.md index de61424..d734005 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,7 +161,9 @@ The aesthetic lives in `app/globals.css` — a retro arcade / CRT theme: Generic shadcn/ui primitives carried over from the base: button, card, dialog, select, tabs, toast/toaster, tooltip, badge, alert, progress, switch, skeleton, -surface-card, page-container, section-title, empty-state, completion-ring, etc. +surface-card, page-container, section-title, empty-state, etc. `animated-text` +wraps the `slot-text` library (slot-machine roll for changing values, with a +`prefers-reduced-motion` fallback) — used for the dashboard value figures. ### Key conventions diff --git a/components/dashboard/inventory-value-panel.tsx b/components/dashboard/inventory-value-panel.tsx index f740c0c..6d50141 100644 --- a/components/dashboard/inventory-value-panel.tsx +++ b/components/dashboard/inventory-value-panel.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react" import { Line, LineChart, ReferenceDot, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts" import { ChevronLeft, ChevronRight, PackageOpen, RefreshCw, TrendingUp } from "lucide-react" +import { AnimatedText } from "@/components/ui/animated-text" import { Button } from "@/components/ui/button" import { surfaceCardVariants } from "@/components/ui/surface-card" import { EmptyState } from "@/components/ui/empty-state" @@ -125,7 +126,9 @@ export function InventoryValuePanel() {

Inventory value

{latest ? ( -

{formatPrice(latest.totalValue, latest.currency)}

+

+ +

) : (

)} @@ -181,6 +184,10 @@ export function InventoryValuePanel() { strokeWidth={2} dot={false} activeDot={{ r: 5 }} + // Without this, every post-sync refetch hands recharts a new data + // array and the line replays its full draw — reads as the whole + // panel reloading. The value roll is the sync feedback instead. + isAnimationActive={false} /> {selectedPoint && ( -

{formatPrice(holdings.totalValue, holdings.currency)}

+

+ +

{holdings.items.length} distinct items

)} diff --git a/components/inventory/holding-row.tsx b/components/inventory/holding-row.tsx index 14ebeae..f451d6e 100644 --- a/components/inventory/holding-row.tsx +++ b/components/inventory/holding-row.tsx @@ -1,6 +1,7 @@ "use client" import { PackageOpen } from "lucide-react" +import { AnimatedText } from "@/components/ui/animated-text" import { surfaceCardVariants } from "@/components/ui/surface-card" import { StickerBadge } from "@/components/inventory/sticker-badge" import { formatPrice } from "@/lib/market" @@ -39,9 +40,13 @@ export function HoldingRow({ item, currency }: { item: HoldingItem; currency: st
{item.unitPrice != null ? ( <> -

{formatPrice(item.lineTotal ?? 0, currency)}

+

+ +

{item.count > 1 && ( -

{formatPrice(item.unitPrice, currency)} / unit

+

+ / unit +

)} ) : ( diff --git a/components/inventory/inventory-list.tsx b/components/inventory/inventory-list.tsx index facd407..cd9529e 100644 --- a/components/inventory/inventory-list.tsx +++ b/components/inventory/inventory-list.tsx @@ -3,6 +3,7 @@ import Link from "next/link" import { useCallback, useEffect, useMemo, useState } from "react" import { RefreshCw, Search, PackageOpen, AlertCircle, Info } from "lucide-react" +import { AnimatedText } from "@/components/ui/animated-text" import { Button } from "@/components/ui/button" import { InputFrame } from "@/components/ui/input-frame" import { Skeleton } from "@/components/ui/skeleton" @@ -76,9 +77,13 @@ function ItemCard({ item, currency }: { item: InventoryItemView; currency: strin
{item.unitPrice != null ? ( <> -

{formatPrice(item.lineTotal ?? 0, currency)}

+

+ +

{item.count > 1 && ( -

{formatPrice(item.unitPrice, currency)} / unit

+

+ / unit +

)} ) : ( @@ -112,19 +117,31 @@ export function InventoryList() { const [sort, setSort] = useState("value-desc") const [syncing, setSyncing] = useState(false) - const load = useCallback(async () => { - setLoading(true) + // `background` keeps the current list on screen (no skeleton swap) so the + // price roll animations can play in place after a sync — including on + // failure, where it toasts instead of wiping the mounted list. + const load = useCallback(async (background = false) => { + if (!background) setLoading(true) setError(null) try { const res = await fetch("/api/inventory/items", { cache: "no-store" }) const body = await res.json() if (!res.ok) { - setError(body.error ?? "Failed to load inventory.") + const message = body.error ?? "Failed to load inventory." + if (background) { + toast({ title: "Refresh failed", description: message, variant: "destructive" }) + return + } + setError(message) setData(null) return } setData(body as InventoryItemsResponse) } catch { + if (background) { + toast({ title: "Network error while refreshing", variant: "destructive" }) + return + } setError("Network error while loading inventory.") } finally { setLoading(false) @@ -145,7 +162,7 @@ export function InventoryList() { return } toast({ title: "Prices refreshed", description: formatPrice(body.totalValue, body.currency) }) - await load() + await load(true) } catch { toast({ title: "Network error", variant: "destructive" }) } finally { @@ -169,7 +186,9 @@ export function InventoryList() { ) : data ? ( <> -

{formatPrice(data.totalValue, data.currency)}

+

+ +

{data.totalItemCount} items · {data.pricedRows} priced

diff --git a/components/inventory/item-price-chart.tsx b/components/inventory/item-price-chart.tsx index 1f4016e..a7aecc3 100644 --- a/components/inventory/item-price-chart.tsx +++ b/components/inventory/item-price-chart.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts" import { TrendingUp } from "lucide-react" +import { AnimatedText } from "@/components/ui/animated-text" import { EmptyState } from "@/components/ui/empty-state" import { surfaceCardVariants } from "@/components/ui/surface-card" import { DateRangeSelector } from "@/components/charts/date-range-selector" @@ -34,7 +35,9 @@ function Stat({ label, value }: { label: string; value: string }) { return (
{label} - {value} + + +
) } diff --git a/components/ui/animated-number.tsx b/components/ui/animated-number.tsx deleted file mode 100644 index 9d73468..0000000 --- a/components/ui/animated-number.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use client" - -import { useEffect, useMemo, useState } from "react" - -type AnimatedNumberProps = { - value: number - className?: string - durationMs?: number -} - -const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] -const DIGIT_HEIGHT_EM = 1.05 - -function shortestCircularOffset(digit: number, activeDigit: number) { - const raw = digit - activeDigit - if (raw > 5) return raw - 10 - if (raw < -5) return raw + 10 - return raw -} - -function DigitColumn({ - digit, - previousDigit, - durationMs, -}: { - digit: string - previousDigit: string - durationMs: number -}) { - const targetDigit = Number(digit) - const startDigit = Number(previousDigit) - const [activeDigit, setActiveDigit] = useState(startDigit) - - useEffect(() => { - const frame = window.requestAnimationFrame(() => { - setActiveDigit(targetDigit) - }) - - return () => { - window.cancelAnimationFrame(frame) - } - }, [targetDigit]) - - return ( - - {DIGITS.map((item) => { - const offset = shortestCircularOffset(Number(item), activeDigit) - - return ( - - {item} - - ) - })} - - ) -} - -export function AnimatedNumber({ value, className, durationMs = 1400 }: AnimatedNumberProps) { - const [previousValue, setPreviousValue] = useState(0) - - const formatter = useMemo(() => new Intl.NumberFormat("en-US", { maximumFractionDigits: 0, useGrouping: false }), []) - - const formattedCurrent = formatter.format(value) - const formattedPrevious = formatter.format(previousValue) - const maxLength = Math.max(formattedCurrent.length, formattedPrevious.length) - const paddedCurrent = formattedCurrent.padStart(maxLength, " ") - const paddedPrevious = formattedPrevious.padStart(maxLength, " ") - - useEffect(() => { - const timeout = setTimeout(() => { - setPreviousValue(value) - }, durationMs) - return () => clearTimeout(timeout) - }, [value, durationMs]) - - return ( - - {paddedCurrent.split("").map((char, index) => { - const previousChar = paddedPrevious[index] ?? " " - - if (!/\d/.test(char)) { - return ( - - {char === " " ? "" : char} - - ) - } - - const safePreviousDigit = /\d/.test(previousChar) ? previousChar : "0" - - return ( - - ) - })} - - ) -} diff --git a/components/ui/animated-text.tsx b/components/ui/animated-text.tsx new file mode 100644 index 0000000..5c224a7 --- /dev/null +++ b/components/ui/animated-text.tsx @@ -0,0 +1,55 @@ +"use client" + +import "slot-text/style.css" +import { useEffect, useState, useSyncExternalStore } from "react" +import { SlotText } from "slot-text/react" +import type { SlotOptions } from "slot-text" + +// Single tuning point for every rolling label in the app. +const ROLL_OPTIONS: SlotOptions = { + duration: 300, + stagger: 45, + bounce: 0.6, +} + +const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)" + +function subscribeToReducedMotion(onChange: () => void) { + const mql = window.matchMedia(REDUCED_MOTION_QUERY) + mql.addEventListener("change", onChange) + return () => mql.removeEventListener("change", onChange) +} + +function prefersReducedMotion() { + return window.matchMedia(REDUCED_MOTION_QUERY).matches +} + +type AnimatedTextProps = { + text: string + className?: string +} + +/** + * Slot-machine roll for short changing labels (values, totals). Rolls in on + * mount too: the first paint masks every digit to 0 (same width, symbols and + * separators static) and the real digits roll in right after, so each page + * load gets the animation — not just later value changes. Falls back to a + * plain under prefers-reduced-motion, which slot-text does not handle + * itself. + */ +export function AnimatedText({ text, className }: AnimatedTextProps) { + const reducedMotion = useSyncExternalStore(subscribeToReducedMotion, prefersReducedMotion, () => false) + const [display, setDisplay] = useState(() => text.replace(/\d/g, "0")) + + useEffect(() => { + setDisplay(text) + }, [text]) + + if (reducedMotion) { + return {text} + } + + // aria-label pins the accessible name to the real value even while the + // masked first frame is on screen. + return +} diff --git a/components/ui/completion-ring.tsx b/components/ui/completion-ring.tsx deleted file mode 100644 index 3197b64..0000000 --- a/components/ui/completion-ring.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client" - -import { useEffect, useId, useState } from "react" - -import { AnimatedNumber } from "@/components/ui/animated-number" - -interface CompletionRingProps { - percent: number - size?: number -} - -// Animated circular progress ring for the dashboard hero stat. The dasharray -// transitions from 0 to the target percent on mount, creating a "filling up" -// effect that anchors the dashboard's first visual moment. -export function CompletionRing({ percent, size = 96 }: CompletionRingProps) { - // Tick after mount so the CSS transition runs from 0 → target. - const [animatedPercent, setAnimatedPercent] = useState(0) - useEffect(() => { - const t = setTimeout(() => setAnimatedPercent(percent), 50) - return () => clearTimeout(t) - }, [percent]) - - const r = 42 - const c = 2 * Math.PI * r - const filled = (animatedPercent / 100) * c - const gap = c - filled - - // Unique gradient id per instance to avoid collisions if the component is - // rendered more than once on the same page. - const uid = useId() - const gradId = `completion-ring-grad-${uid}` - - return ( -
- -
- - - % - - Avg -
-
- ) -} diff --git a/package.json b/package.json index 567dacb..a98b7b6 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "react-dom": "^19.2.4", "react-hook-form": "^7.79.0", "recharts": "3.8.1", + "slot-text": "0.3.2", "sonner": "^2.0.7", "tailwind-merge": "^3.4.1", "zod": "4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a8df02..8d0dd3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: recharts: specifier: 3.8.1 version: 3.8.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1) + slot-text: + specifier: 0.3.2 + version: 0.3.2(react@19.2.7) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2449,6 +2452,23 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + slot-text@0.3.2: + resolution: {integrity: sha512-UT80AGwUsxdYAlE6JAUAntUPVT2vQUCE9nx3rDyDTB3fSvjTv+hjwPbtukJDCE86MDJEh4r8zlE7rYIgeOs45A==} + peerDependencies: + react: '>=18 <20' + solid-js: '>=1.8 <2' + svelte: '>=4 <6' + vue: '>=3.4 <4' + peerDependenciesMeta: + react: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -4861,6 +4881,10 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + slot-text@0.3.2(react@19.2.7): + optionalDependencies: + react: 19.2.7 + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 diff --git a/test/animated-text.test.tsx b/test/animated-text.test.tsx new file mode 100644 index 0000000..288107a --- /dev/null +++ b/test/animated-text.test.tsx @@ -0,0 +1,47 @@ +import { render } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" +import { AnimatedText } from "@/components/ui/animated-text" + +function mockMatchMedia(matches: boolean) { + vi.stubGlobal( + "matchMedia", + vi.fn().mockReturnValue({ + matches, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as MediaQueryList), + ) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("AnimatedText", () => { + it("renders the text through slot-text when motion is allowed", () => { + mockMatchMedia(false) + const { container } = render() + // slot-text splits the label into per-character cells (each holding old and + // new glyphs), so the accessible name — not textContent — is the contract. + const root = container.querySelector(".slot-text") + expect(root).not.toBeNull() + expect(root).toHaveAttribute("aria-label", "$1,234.56") + }) + + it("falls back to a plain span under prefers-reduced-motion", () => { + mockMatchMedia(true) + const { container } = render() + const span = container.querySelector("span.text-accent") + expect(span).not.toBeNull() + expect(span?.textContent).toBe("$1,234.56") + // Plain fallback: no slot-text cell structure inside. + expect(span?.children.length).toBe(0) + }) + + it("updates the accessible name when the prop changes", () => { + mockMatchMedia(false) + const { container, rerender } = render() + rerender() + expect(container.querySelector(".slot-text")).toHaveAttribute("aria-label", "$12.50") + }) +})