Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions __tests__/css-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
96 changes: 96 additions & 0 deletions scripts/check-css.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
}
10 changes: 5 additions & 5 deletions src/app/alerts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
<Text fontSize="xs" color="#ff8080" mt={2}>
<Text fontSize="xs" color="app.errorFg" mt={2}>
{apiKeyError}
</Text>
)}
Expand Down Expand Up @@ -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}
>
Expand Down
2 changes: 1 addition & 1 deletion src/app/farm/[poolId]/PoolDetailClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ export default function PoolDetailClient({ poolId }: { poolId: string }) {
_focus={{ boxShadow: "none", borderColor: "app.accent" }}
/>
{rawAmount !== "0" && rawAmount !== "" && !amountValid && (
<Text fontSize="xs" color="#ff8080">
<Text fontSize="xs" color="app.errorFg">
Enter an amount greater than 0.
</Text>
)}
Expand Down
4 changes: 2 additions & 2 deletions src/app/farm/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,12 +274,12 @@ function DepositModal({
</Text>
</Box>
{!!trimmedAmount && !amountValid && (
<Text fontSize="xs" color="#ff8080">
<Text fontSize="xs" color="app.errorFg">
Enter a positive amount with no more than 7 decimals.
</Text>
)}
{exceedsBalance && (
<Text fontSize="xs" color="#ff8080">
<Text fontSize="xs" color="app.errorFg">
Amount exceeds your Horizon XLM balance.
</Text>
)}
Expand Down
4 changes: 2 additions & 2 deletions src/app/history/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,8 @@ export default function HistoryPage() {
)}

{truncated && (
<Alert status="warning" borderRadius="2xl" mt={4} bg="#2a2412" color="#f6c453" maxW="1000px" w="full">
<AlertIcon color="#f6c453" />
<Alert status="warning" borderRadius="2xl" mt={4} bg="app.warningBg" color="app.warningFg" maxW="1000px" w="full">
<AlertIcon color="app.warningFg" />
<Text fontSize="sm">Some history may be missing — the result was truncated. Older transactions may not be shown.</Text>
</Alert>
)}
Expand Down
2 changes: 1 addition & 1 deletion src/app/webhooks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
Expand Down
12 changes: 6 additions & 6 deletions src/components/BoostModal/BoostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,11 @@ export default function BoostModal() {
<Alert
status="warning"
borderRadius="2xl"
bg="#2a2412"
color="#f6c453"
bg="app.warningBg"
color="app.warningFg"
fontSize="sm"
>
<AlertIcon color="#f6c453" />
<AlertIcon color="app.warningFg" />
You need to deposit to this pool before setting a boost allocation.
</Alert>
)}
Expand Down Expand Up @@ -238,11 +238,11 @@ export default function BoostModal() {
<Alert
status="error"
borderRadius="2xl"
bg="#2a1414"
color="#ff8080"
bg="app.errorBg"
color="app.errorFg"
fontSize="sm"
>
<AlertIcon color="#ff8080" />
<AlertIcon color="app.errorFg" />
{error}
</Alert>
)}
Expand Down
2 changes: 1 addition & 1 deletion src/components/OnboardingOverlay/OnboardingOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export default function OnboardingOverlay() {
trapFocus
returnFocusOnClose
>
<ModalOverlay backdropFilter="blur(5px)" bg="blackAlpha.600" />
<ModalOverlay backdropFilter="blur(5px)" bg="app.overlay" />
<ModalContent
bg="app.surface"
color="app.text"
Expand Down
8 changes: 2 additions & 6 deletions src/components/PlatformStats/PlatformStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@
import React from 'react';
import { SimpleGrid, Stat, StatLabel, StatNumber, Box, Skeleton } from '@chakra-ui/react';
import { usePlatformStats, UIPlatformStats } from '@/hooks/useSorobanQuery';

const formatRawCredits = (value: string): string => {
const num = parseFloat(value) || 0;
return `${(num / 1_000_000).toFixed(1)}M XLM`;
};
import { formatCredits } from '@/lib/soroban';

interface PlatformStatsProps {
initialData?: UIPlatformStats;
Expand Down Expand Up @@ -68,7 +64,7 @@ export const PlatformStats: React.FC<PlatformStatsProps> = ({ initialData }) =>
<StatLabel color="app.muted">24h Credit Velocity</StatLabel>
<Skeleton isLoaded={!isLoading || !!stats} startColor="app.border" endColor="app.surfaceHover">
<StatNumber fontSize="2xl" fontWeight="extrabold" color="app.accent2">
{stats ? formatRawCredits(stats.creditVelocity) : '0.0M XLM'}
{stats ? `${formatCredits(stats.creditVelocity)} XLM` : '0 XLM'}
</StatNumber>
</Skeleton>
</Stat>
Expand Down
2 changes: 1 addition & 1 deletion src/components/UnlockModal/UnlockModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ export default function UnlockModal() {
</Flex>

{!!amount && !amountValid && (
<Text fontSize="xs" color="#ff8080" role="alert">
<Text fontSize="xs" color="app.errorFg" role="alert">
{numericAmount < 0.01
? `Minimum unlock amount is 0.01 ${position.symbol}.`
: numericAmount > position.lockedAmount
Expand Down
13 changes: 12 additions & 1 deletion src/context/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
17 changes: 8 additions & 9 deletions src/hooks/useToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand All @@ -154,7 +153,7 @@ export function useToast() {

return normalized;
},
[chakraToast, retryBorderColor, retryHoverBg]
[chakraToast]
);

/**
Expand Down
8 changes: 8 additions & 0 deletions src/lib/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down