From 557765a476b0ab8440da6bc5527003bf1e76a5ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 21:44:12 +0000 Subject: [PATCH 01/14] =?UTF-8?q?feat(theme):=20design-token=20foundation?= =?UTF-8?q?=20=E2=80=94=20real=20Inter=20font,=20semantic=20tokens,=20unif?= =?UTF-8?q?ied=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Load Inter via next/font/google (was declared in CSS but never loaded, silently falling back to system-ui); define font-headline so the existing font-headline classes stop being a no-op - Add semantic success/warning/info tokens (light + dark) to globals.css and register them in the Tailwind v4 @theme block - Give --accent its own subtle warm surface instead of duplicating the primary Bitcoin orange, so menu/dropdown hovers stop flashing brand color - Unify the dark-mode chart palette with light mode (same Bitcoin-anchored hues, lightness-tuned) — charts no longer change identity across themes - Replace the fragile hardcoded-hex [role=alert] CSS overrides with a token-based Alert warning variant - Delete tailwind.config.ts: dead v3-style duplicate of the CSS @theme under Tailwind v4 (no @config directive references it) - disableTransitionOnChange on ThemeProvider (kills theme-toggle flash); styled branded Suspense fallback instead of unstyled "Loading..." Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016A3N8t6WuMeHgiKQLWFN7T --- components.json | 2 +- src/app/globals.css | 75 ++++++++++++++------------ src/app/layout.tsx | 26 ++++++--- src/components/ui/alert.tsx | 2 +- tailwind.config.ts | 102 ------------------------------------ 5 files changed, 64 insertions(+), 143 deletions(-) delete mode 100644 tailwind.config.ts diff --git a/components.json b/components.json index 7b17557f..8744a71a 100644 --- a/components.json +++ b/components.json @@ -4,7 +4,7 @@ "rsc": true, "tsx": true, "tailwind": { - "config": "tailwind.config.ts", + "config": "", "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, diff --git a/src/app/globals.css b/src/app/globals.css index ab4a2a32..87cb3c13 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -42,14 +42,21 @@ --color-brand: hsl(var(--brand)); --color-treemap: hsl(var(--treemap)); --color-progress: hsl(var(--progress)); - + --color-success: hsl(var(--success)); + --color-success-foreground: hsl(var(--success-foreground)); + --color-warning: hsl(var(--warning)); + --color-warning-foreground: hsl(var(--warning-foreground)); + --color-info: hsl(var(--info)); + --color-info-foreground: hsl(var(--info-foreground)); + /* Border radius - using Tailwind v4 @theme syntax */ --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 4px); - /* Font family */ - --font-body: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + /* Font family (--font-inter is injected by next/font in the root layout) */ + --font-body: var(--font-inter), 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-headline: var(--font-inter), 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; /* Animations - using Tailwind v4 @theme syntax */ --animate-accordion-down: accordion-down 0.2s ease-out; @@ -85,22 +92,6 @@ } } -/* Warning alert text colors - explicit overrides for light mode visibility */ -[role="alert"].bg-orange-100 > h5, -[role="alert"].bg-orange-100 > div { - color: #431407; /* orange-950 */ -} -[role="alert"].bg-orange-100 > svg { - color: #c2410c; /* orange-700 */ -} -.dark [role="alert"].dark\:bg-orange-500\/20 > h5, -.dark [role="alert"].dark\:bg-orange-500\/20 > div { - color: #fff7ed; /* orange-50 */ -} -.dark [role="alert"].dark\:bg-orange-500\/20 > svg { - color: #fb923c; /* orange-400 */ -} - @layer base { :root { --background: 0 0% 100%; @@ -117,18 +108,27 @@ --secondary-foreground: 0 0% 9%; --muted: 0 0% 96.1%; --muted-foreground: 0 0% 45.1%; - --accent: 33 93% 54%; /* Bitcoin orange #F7931A */ - --accent-foreground: 0 0% 100%; + /* Subtle warm hover surface — distinct from primary so menus/dropdowns don't flash full brand orange */ + --accent: 33 60% 94%; + --accent-foreground: 26 60% 18%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; + /* Semantic status tokens */ + --success: 158 82% 42%; + --success-foreground: 0 0% 100%; + --warning: 38 92% 50%; + --warning-foreground: 26 83% 14%; + --info: 217 91% 60%; + --info-foreground: 0 0% 100%; --border: 0 0% 89.8%; --input: 0 0% 89.8%; --ring: 33 93% 54%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; + /* Bitcoin-anchored categorical ramp — same hues in dark mode, lightness-tuned there */ + --chart-1: 33 93% 54%; /* Bitcoin orange */ + --chart-2: 173 58% 39%; /* teal */ + --chart-3: 226 70% 55%; /* indigo */ + --chart-4: 43 90% 50%; /* gold */ + --chart-5: 276 70% 60%; /* purple */ --chart-positive: 158 82% 42%; --chart-negative: 350 89% 60%; --chart-received: 350 89% 60%; /* Match chart-negative for consistency */ @@ -164,18 +164,27 @@ --secondary-foreground: 0 0% 98%; --muted: 0 0% 14.9%; --muted-foreground: 0 0% 63.9%; - --accent: 33 93% 54%; /* Bitcoin orange #F7931A */ - --accent-foreground: 0 0% 100%; + /* Subtle warm hover surface — distinct from primary so menus/dropdowns don't flash full brand orange */ + --accent: 33 20% 18%; + --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 0% 98%; + /* Semantic status tokens */ + --success: 158 70% 45%; + --success-foreground: 0 0% 100%; + --warning: 38 92% 55%; + --warning-foreground: 26 83% 14%; + --info: 217 91% 65%; + --info-foreground: 0 0% 100%; --border: 0 0% 20%; --input: 0 0% 20%; --ring: 33 93% 54%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; + /* Same categorical hues as light mode, lightness-tuned for dark surfaces */ + --chart-1: 33 93% 58%; /* Bitcoin orange */ + --chart-2: 173 55% 45%; /* teal */ + --chart-3: 226 75% 64%; /* indigo */ + --chart-4: 43 90% 58%; /* gold */ + --chart-5: 276 75% 68%; /* purple */ --chart-positive: 158 82% 42%; /* Match light mode for consistency */ --chart-negative: 350 89% 60%; /* Match light mode for consistency */ --chart-received: 350 89% 60%; /* Match chart-negative for consistency */ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 016a1e1f..4be003bb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from 'next'; import { Suspense } from 'react'; +import { Inter } from 'next/font/google'; import './globals.css'; import { Toaster } from '@/components/ui/toaster'; import { AnalyticsTracker } from '@/components/analytics-tracker'; @@ -10,6 +11,12 @@ import { ErrorBoundary } from '@/components/error-boundary'; import { SpeedInsights } from '@vercel/speed-insights/next'; import { Analytics } from '@vercel/analytics/next'; +const inter = Inter({ + subsets: ['latin'], + display: 'swap', + variable: '--font-inter', +}); + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://app.bitsleuth.ai'; export const metadata: Metadata = { @@ -99,7 +106,7 @@ export default function RootLayout({ const gaId = process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID; return ( - + @@ -154,12 +161,19 @@ export default function RootLayout({ - Loading...}> - +
+ Loading BitSleuth… +
+ } + > + diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index 68712663..6c9969fe 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -12,7 +12,7 @@ const alertVariants = cva( destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", warning: - "border-orange-500/50 bg-orange-100 text-orange-950 [&>h5]:text-orange-950 [&>div]:text-orange-900 [&>svg]:text-orange-700 dark:border-orange-500/40 dark:bg-orange-500/20 dark:text-orange-50 dark:[&>h5]:text-orange-50 dark:[&>div]:text-orange-100 dark:[&>svg]:text-orange-400", + "border-warning/50 bg-warning/15 text-foreground [&>svg]:text-warning", }, }, defaultVariants: { diff --git a/tailwind.config.ts b/tailwind.config.ts deleted file mode 100644 index ed2dccf0..00000000 --- a/tailwind.config.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { Config } from "tailwindcss"; - -const config: Config = { - darkMode: "class", - content: [ - "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", - "./src/components/**/*.{js,ts,jsx,tsx,mdx}", - "./src/app/**/*.{js,ts,jsx,tsx,mdx}", - ], - theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - sidebar: { - DEFAULT: "hsl(var(--sidebar-background))", - foreground: "hsl(var(--sidebar-foreground))", - primary: "hsl(var(--sidebar-primary))", - "primary-foreground": "hsl(var(--sidebar-primary-foreground))", - accent: "hsl(var(--sidebar-accent))", - "accent-foreground": "hsl(var(--sidebar-accent-foreground))", - border: "hsl(var(--sidebar-border))", - ring: "hsl(var(--sidebar-ring))", - }, - chart: { - "1": "hsl(var(--chart-1))", - "2": "hsl(var(--chart-2))", - "3": "hsl(var(--chart-3))", - "4": "hsl(var(--chart-4))", - "5": "hsl(var(--chart-5))", - positive: "hsl(var(--chart-positive))", - negative: "hsl(var(--chart-negative))", - received: "hsl(var(--chart-received))", - sent: "hsl(var(--chart-sent))", - purple: "hsl(var(--chart-purple))", - }, - brand: "hsl(var(--brand))", - treemap: "hsl(var(--treemap))", - progress: "hsl(var(--progress))", - }, - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - keyframes: { - "accordion-down": { - from: { height: "0" }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: "0" }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, - }, - }, -}; - -export default config; From 10a8be1e3609361baaf0dd18dbb8ceccceeda01e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 21:49:48 +0000 Subject: [PATCH 02/14] perf: lazy crypto stack, memoized wallet context, on-demand export libs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Load @/lib/blockchain (bitcoinjs-lib + bip32 + ecpair + secp256k1) via dynamic import inside getWalletData instead of statically from the app-wide wallet context. The crypto stack leaves every page's initial bundle (largest eager chunk: 820KB -> 448KB) and loads only when wallet data is actually fetched. Derivation stays client-side — xpubs never leave the browser. - Memoize the WalletContext provider value (was a fresh ~40-field object every render), so the 5-minute price poll and streaming discovery progress no longer re-render every useWallet() consumer app-wide - Load jspdf/jspdf-autotable and jszip on demand inside the report export click handlers instead of bundling them with the report page - Add recharts to experimental.optimizePackageImports - Remove the dead update poller in the app layout: it fetched full page HTML with no-store every 15 minutes and regexed for __NEXT_DATA__, which the App Router never emits — it could never detect an update Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016A3N8t6WuMeHgiKQLWFN7T --- next.config.ts | 2 +- src/app/(app)/layout.tsx | 108 ------------------------- src/app/(app)/report/enhanced-page.tsx | 13 +-- src/contexts/wallet-context.tsx | 91 ++++++++++++++++----- src/lib/tax-export.ts | 7 +- 5 files changed, 85 insertions(+), 136 deletions(-) diff --git a/next.config.ts b/next.config.ts index 4f243421..69dfa1f0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -150,7 +150,7 @@ const nextConfig: NextConfig = { // Updated experimental flags for Next.js 16 experimental: { webVitalsAttribution: ['CLS', 'LCP'], - optimizePackageImports: ['@radix-ui/react-icons', 'lucide-react'], + optimizePackageImports: ['@radix-ui/react-icons', 'lucide-react', 'recharts'], }, // Turbopack configuration (replaces webpack) // Turbopack has native WASM support - no custom config needed diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index f28e268c..0f638a6d 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -310,8 +310,6 @@ function AppLayoutInner({ children }: { children: React.ReactNode }) { const { toast } = useToast(); const [isNostrDialogOpen, setNostrDialogOpen] = React.useState(false); const [isEditProfileOpen, setEditProfileOpen] = React.useState(false); - const [isUpdateAvailable, setIsUpdateAvailable] = React.useState(false); - const [isUpdateDismissed, setIsUpdateDismissed] = React.useState(false); // Define public paths that do not require a wallet to be connected. const publicPaths = ['/market', '/mempool', '/discover', '/block/', '/transactions/', '/address/']; @@ -364,93 +362,6 @@ function AppLayoutInner({ children }: { children: React.ReactNode }) { } }); - // Effect for checking for new version - React.useEffect(() => { - // This check should only run on the client. - if (typeof window === 'undefined') { - return; - } - - // For development: pop up the alert after 1 minute to make it testable. - if (process.env.NODE_ENV === 'development') { - const devTimeout = setTimeout(() => { - setIsUpdateAvailable(true); - }, 60000); // 1 minute - return () => clearTimeout(devTimeout); - } - - // For production: check against the build ID. - if (process.env.NODE_ENV === 'production' && window.__NEXT_DATA__) { - const currentBuildId = window.__NEXT_DATA__.buildId; - - // This fetch hits the App Hosting origin (Cloud Run) with `no-store`, - // so it is both a CDN miss and an origin request. Keep it infrequent, - // skip it while the tab is hidden, and add jitter so clients don't all - // poll in lockstep. - const BASE_INTERVAL = 15 * 60 * 1000; // 15 minutes - const JITTER = 60 * 1000; // up to 60s of spread - let timeoutId: ReturnType; - let stopped = false; - - const scheduleNext = () => { - const delay = BASE_INTERVAL + Math.floor(Math.random() * JITTER); - timeoutId = setTimeout(check, delay); - }; - - const check = async () => { - if (stopped) return; - - // Don't poll the origin while the tab is in the background. - if (document.visibilityState !== 'visible') { - scheduleNext(); - return; - } - - try { - const res = await fetch(window.location.href, { - cache: 'no-store', - }); - - if (!res.ok) { - console.warn(`Update check failed with status: ${res.status}`); - scheduleNext(); - return; - } - - const html = await res.text(); - const match = html.match(/