From 90a9cb5ec7b6eb96409556b6625fddc2204bedc5 Mon Sep 17 00:00:00 2001 From: dev-markdavid Date: Fri, 25 Sep 2026 07:10:10 +0100 Subject: [PATCH] fix: resolve issues #478-#481 - #478: Route colors that bypassed the theme through semantic tokens: add app.overlay and app.toastRetryBorder/app.toastRetryHoverBg (replacing the blackAlpha.600 / whiteAlpha.600 / blackAlpha.400 literals in OnboardingOverlay and useToast), and swap hardcoded #ff8080 / #2a1414 / #2a2412 / #f6c453 error-warning hexes for app.errorFg / app.errorBg / app.warningBg / app.warningFg in alerts, farm, pool detail, webhooks, history, UnlockModal and BoostModal - #479: Drop PlatformStats' local always-divide-by-1M credit formatter and use the shared formatCredits from src/lib/soroban.ts, which handles K/M suffixes, placeholders and non-numeric input consistently - #480: Add scripts/check-css.mjs to parse every stylesheet with PostCSS and run it as part of `pnpm build` (plus a `lint:css` script and a vitest guard), so a syntax error in globals.css fails the pipeline with a file:line diagnostic instead of silently un-styling the app - #481: Give the shared QueryClient defaultOptions.queries a baseline staleTime (30s) and gcTime (5m); hooks that need a different freshness window still override per-query, but queries without explicit options no longer fall back to React Query's staleTime: 0 Verified with `pnpm typecheck`, `vitest run`, `pnpm build` and `pnpm bundle:budget`. The test failures left in `vitest run` also fail on main (soroban.service/leaderboard/useLeaderboard timing and alerts retry flakes) and are unrelated to these changes. --- __tests__/css-validation.test.ts | 17 ++++ package.json | 3 +- scripts/check-css.mjs | 96 +++++++++++++++++++ src/app/alerts/page.tsx | 10 +- src/app/farm/[poolId]/PoolDetailClient.tsx | 2 +- src/app/farm/page.tsx | 4 +- src/app/history/page.tsx | 4 +- src/app/webhooks/page.tsx | 2 +- src/components/BoostModal/BoostModal.tsx | 12 +-- .../OnboardingOverlay/OnboardingOverlay.tsx | 2 +- .../PlatformStats/PlatformStats.tsx | 8 +- src/components/UnlockModal/UnlockModal.tsx | 2 +- src/context/index.tsx | 13 ++- src/hooks/useToast.tsx | 17 ++-- src/lib/theme.ts | 8 ++ 15 files changed, 164 insertions(+), 36 deletions(-) create mode 100644 __tests__/css-validation.test.ts create mode 100644 scripts/check-css.mjs diff --git a/__tests__/css-validation.test.ts b/__tests__/css-validation.test.ts new file mode 100644 index 0000000..78f29ad --- /dev/null +++ b/__tests__/css-validation.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { validateCssFiles } from "../scripts/check-css.mjs"; + +/** + * Guards the styling pipeline (issue #480): a syntax error in globals.css + * would otherwise only show up as a silently unstyled app, because nothing + * validated the CSS before the build. Shares its implementation with the + * `lint:css` / `build` script so CI and the local pipeline fail the same way. + */ +describe("CSS validation", () => { + it("parses every stylesheet in src without syntax errors", async () => { + const { files, errors } = await validateCssFiles(); + + expect(files.length).toBeGreaterThan(0); + expect(errors).toEqual([]); + }); +}); diff --git a/package.json b/package.json index 6b88172..0b8c45c 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,11 @@ "scripts": { "dev": "next dev", "dev:stack": "bash scripts/dev-stack.sh", - "build": "next build", + "build": "node scripts/check-css.mjs && next build", "start": "next start", "typecheck": "tsc --noEmit", "lint": "next lint", + "lint:css": "node scripts/check-css.mjs", "sync-contributors": "node scripts/sync-contributors.mjs", "audit:high": "pnpm audit --prod --audit-level high", "test": "vitest run", diff --git a/scripts/check-css.mjs b/scripts/check-css.mjs new file mode 100644 index 0000000..762fc5e --- /dev/null +++ b/scripts/check-css.mjs @@ -0,0 +1,96 @@ +/** + * Pre-build CSS validation (issue #480). + * + * `next build` compiles CSS through PostCSS but only fails loudly on some + * classes of error, and a broken globals.css otherwise surfaces as silently + * unstyled pages. Parsing every stylesheet with PostCSS up front makes any + * syntax error fail the build with a file/line/column diagnostic. + * + * Usage: + * node scripts/check-css.mjs (also wired into `pnpm build`) + */ + +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import postcss from "postcss"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const CSS_ROOTS = ["src"]; + +async function collectCssFiles(dir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return []; + } + + const files = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules") continue; + files.push(...(await collectCssFiles(fullPath))); + } else if (entry.isFile() && entry.name.endsWith(".css")) { + files.push(fullPath); + } + } + return files; +} + +/** + * Parses every CSS file under the known roots. + * @returns {Promise<{ files: string[], errors: { file: string, line?: number, column?: number, message: string }[] }>} + */ +export async function validateCssFiles() { + const files = []; + for (const root of CSS_ROOTS) { + files.push(...(await collectCssFiles(path.join(ROOT, root)))); + } + files.sort(); + + const errors = []; + for (const file of files) { + const source = await readFile(file, "utf8"); + try { + await postcss.parse(source, { from: file }); + } catch (error) { + errors.push({ + file: path.relative(ROOT, file), + line: error.line, + column: error.column, + message: error.reason ?? String(error.message ?? error), + }); + } + } + + return { files, errors }; +} + +async function main() { + const { files, errors } = await validateCssFiles(); + + if (errors.length > 0) { + for (const error of errors) { + const location = error.line ? `:${error.line}:${error.column ?? 0}` : ""; + console.error(`[css] ERROR ${error.file}${location} - ${error.message}`); + } + console.error( + `[css] Validation failed: ${errors.length} of ${files.length} stylesheet(s) could not be parsed.` + ); + process.exit(1); + } + + console.log(`[css] Validation passed: ${files.length} stylesheet(s) parsed.`); +} + +const isDirectRun = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isDirectRun) { + main().catch((error) => { + console.error(`[css] Validation could not run: ${error?.message ?? error}`); + process.exit(1); + }); +} diff --git a/src/app/alerts/page.tsx b/src/app/alerts/page.tsx index 3a0a4c0..10d61ee 100644 --- a/src/app/alerts/page.tsx +++ b/src/app/alerts/page.tsx @@ -138,16 +138,16 @@ export default function AlertsPage() { placeholder="Your smartdrop-backend API key" fontFamily="mono" fontSize="sm" - borderColor={apiKeyError ? "#ff8080" : "app.border"} + borderColor={apiKeyError ? "app.errorFg" : "app.border"} bg="app.inputBg" isInvalid={Boolean(apiKeyError)} aria-invalid={Boolean(apiKeyError)} aria-describedby="api-key-help" - _hover={{ borderColor: apiKeyError ? "#ff8080" : "app.accent" }} - _focus={{ boxShadow: "none", borderColor: apiKeyError ? "#ff8080" : "app.accent" }} + _hover={{ borderColor: apiKeyError ? "app.errorFg" : "app.accent" }} + _focus={{ boxShadow: "none", borderColor: apiKeyError ? "app.errorFg" : "app.accent" }} /> {apiKeyError && ( - + {apiKeyError} )} @@ -304,7 +304,7 @@ export default function AlertsPage() { size="sm" variant="outline" borderColor="app.border" - color="#ff8080" + color="app.errorFg" onClick={() => deleteMutation.mutate(alert.id)} isLoading={deleteMutation.isPending && deleteMutation.variables === alert.id} > diff --git a/src/app/farm/[poolId]/PoolDetailClient.tsx b/src/app/farm/[poolId]/PoolDetailClient.tsx index 31ec189..b1d4f22 100644 --- a/src/app/farm/[poolId]/PoolDetailClient.tsx +++ b/src/app/farm/[poolId]/PoolDetailClient.tsx @@ -444,7 +444,7 @@ export default function PoolDetailClient({ poolId }: { poolId: string }) { _focus={{ boxShadow: "none", borderColor: "app.accent" }} /> {rawAmount !== "0" && rawAmount !== "" && !amountValid && ( - + Enter an amount greater than 0. )} diff --git a/src/app/farm/page.tsx b/src/app/farm/page.tsx index 1572e17..2f00a8c 100644 --- a/src/app/farm/page.tsx +++ b/src/app/farm/page.tsx @@ -274,12 +274,12 @@ function DepositModal({ {!!trimmedAmount && !amountValid && ( - + Enter a positive amount with no more than 7 decimals. )} {exceedsBalance && ( - + Amount exceeds your Horizon XLM balance. )} diff --git a/src/app/history/page.tsx b/src/app/history/page.tsx index cee33fb..23e6806 100644 --- a/src/app/history/page.tsx +++ b/src/app/history/page.tsx @@ -352,8 +352,8 @@ export default function HistoryPage() { )} {truncated && ( - - + + Some history may be missing — the result was truncated. Older transactions may not be shown. )} diff --git a/src/app/webhooks/page.tsx b/src/app/webhooks/page.tsx index dbfedb4..ab8e30a 100644 --- a/src/app/webhooks/page.tsx +++ b/src/app/webhooks/page.tsx @@ -270,7 +270,7 @@ export default function WebhooksPage() { size="sm" variant="outline" borderColor="app.border" - color="#ff8080" + color="app.errorFg" onClick={() => handleDeleteClick(webhook.id)} isLoading={deleteMutation.isPending && deleteMutation.variables === webhook.id} > diff --git a/src/components/BoostModal/BoostModal.tsx b/src/components/BoostModal/BoostModal.tsx index ec581b3..a9d44cc 100644 --- a/src/components/BoostModal/BoostModal.tsx +++ b/src/components/BoostModal/BoostModal.tsx @@ -181,11 +181,11 @@ export default function BoostModal() { - + You need to deposit to this pool before setting a boost allocation. )} @@ -238,11 +238,11 @@ export default function BoostModal() { - + {error} )} diff --git a/src/components/OnboardingOverlay/OnboardingOverlay.tsx b/src/components/OnboardingOverlay/OnboardingOverlay.tsx index 5458fd3..d1560fb 100644 --- a/src/components/OnboardingOverlay/OnboardingOverlay.tsx +++ b/src/components/OnboardingOverlay/OnboardingOverlay.tsx @@ -77,7 +77,7 @@ export default function OnboardingOverlay() { trapFocus returnFocusOnClose > - + { - const num = parseFloat(value) || 0; - return `${(num / 1_000_000).toFixed(1)}M XLM`; -}; +import { formatCredits } from '@/lib/soroban'; interface PlatformStatsProps { initialData?: UIPlatformStats; @@ -68,7 +64,7 @@ export const PlatformStats: React.FC = ({ initialData }) => 24h Credit Velocity - {stats ? formatRawCredits(stats.creditVelocity) : '0.0M XLM'} + {stats ? `${formatCredits(stats.creditVelocity)} XLM` : '0 XLM'} diff --git a/src/components/UnlockModal/UnlockModal.tsx b/src/components/UnlockModal/UnlockModal.tsx index 7376ef5..afa66a7 100644 --- a/src/components/UnlockModal/UnlockModal.tsx +++ b/src/components/UnlockModal/UnlockModal.tsx @@ -430,7 +430,7 @@ export default function UnlockModal() { {!!amount && !amountValid && ( - + {numericAmount < 0.01 ? `Minimum unlock amount is 0.01 ${position.symbol}.` : numericAmount > position.lockedAmount diff --git a/src/context/index.tsx b/src/context/index.tsx index 53dbd01..0cc716f 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -17,7 +17,18 @@ declare global { function ContextProvider({ children }: { children: ReactNode }) { const [queryClient] = useState(() => { - return new QueryClient(); + return new QueryClient({ + // Baseline caching defaults (issue #481). Every query now inherits a + // sane staleTime/gcTime instead of React Query's staleTime: 0 — hooks + // that need a different freshness window (5s/15s/60s) still override + // these per-query in useSorobanQuery.ts. + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 5 * 60 * 1000, + }, + }, + }); }); useEffect(() => { diff --git a/src/hooks/useToast.tsx b/src/hooks/useToast.tsx index ec10a11..701a33a 100644 --- a/src/hooks/useToast.tsx +++ b/src/hooks/useToast.tsx @@ -11,7 +11,7 @@ import { normalizeError, withRetry } from "@/lib/error-handler"; -import { Box, Button, Text, useColorModeValue, useToast as useChakraToast } from "@chakra-ui/react"; +import { Box, Button, Text, useToast as useChakraToast } from "@chakra-ui/react"; import { useCallback } from "react"; export type NotificationType = "success" | "error" | "info" | "warning"; @@ -31,12 +31,11 @@ const DEFAULT_TOAST_OPTIONS: ToastOptions = { export function useToast() { const chakraToast = useChakraToast(); - // Retry-button colors inside the error toast (issue #450). The toast is a + // Retry button inside the error toast (issues #450 and #478). The toast is a // solid Alert: light mode paints red.600 with white text, dark mode paints - // red.200 with near-black text — so a single hardcoded whiteAlpha pair can - // only ever match one of them. Derive both from the active color mode. - const retryBorderColor = useColorModeValue("whiteAlpha.600", "blackAlpha.400"); - const retryHoverBg = useColorModeValue("whiteAlpha.200", "blackAlpha.100"); + // red.200 with near-black text — so its border/hover tint comes from the + // app.toastRetryBorder / app.toastRetryHoverBg semantic tokens, which flip + // with the color mode instead of hardcoding a whiteAlpha/blackAlpha pair. /** * Show a success notification. @@ -129,9 +128,9 @@ export function useToast() { size="sm" mt={2} variant="outline" - borderColor={retryBorderColor} + borderColor="app.toastRetryBorder" color="inherit" - _hover={{ bg: retryHoverBg }} + _hover={{ bg: "app.toastRetryHoverBg" }} onClick={() => { chakraToast.closeAll(); onRetry(); @@ -154,7 +153,7 @@ export function useToast() { return normalized; }, - [chakraToast, retryBorderColor, retryHoverBg] + [chakraToast] ); /** diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 6eb3777..592bd83 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -47,6 +47,14 @@ const theme = extendTheme({ "app.feeWarnBg": { default: "#fef3cd", _dark: "#2d2216" }, "app.feeWarnFg": { default: "#9a6b00", _dark: "#ffb86c" }, "app.feeWarnBorder": { default: "#c9a84c", _dark: "#7c5c24" }, + // Overlays — modal scrim (was hardcoded blackAlpha.600) + "app.overlay": { default: "rgba(0, 0, 0, 0.6)", _dark: "rgba(0, 0, 0, 0.6)" }, + // Retry action rendered inside the error toast. The toast paints + // red.600 + white text in light mode and red.200 + near-black text in + // dark mode, so the border/hover tint has to flip with the color mode + // (was whiteAlpha.600 / whiteAlpha.200 vs blackAlpha.400 / blackAlpha.100). + "app.toastRetryBorder": { default: "rgba(255, 255, 255, 0.6)", _dark: "rgba(0, 0, 0, 0.4)" }, + "app.toastRetryHoverBg": { default: "rgba(255, 255, 255, 0.2)", _dark: "rgba(0, 0, 0, 0.1)" }, }, }, styles: {