diff --git a/README.md b/README.md
index 220a252..fadeebe 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,12 @@ Next.js frontend application for [StableRoute](https://github.com/StableRoute-Or
- **TailwindCSS** for styling
- A comprehensive set of routing, management, and audit log pages integrated with the StableRoute backend.
+## UI Styleguide
+
+Contributor-facing Badge variants, Button variants, focus-ring conventions, and
+light/dark color pairs are documented in
+[`docs/STYLEGUIDE.md`](docs/STYLEGUIDE.md).
+
## Routes
Each route is defined under `src/app` and connects to its respective UI page:
diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md
new file mode 100644
index 0000000..271c5bc
--- /dev/null
+++ b/docs/STYLEGUIDE.md
@@ -0,0 +1,89 @@
+# UI Styleguide
+
+This guide documents the shared UI tokens that contributors should reuse when
+adding StableRoute surfaces. The current implementation keeps design tokens
+close to the components with Tailwind utility classes, so prefer the existing
+components before adding new variants.
+
+## Badge Variants
+
+Use `src/components/Badge.tsx` for short statuses, labels, and compact metadata.
+Badges are rendered as rounded pills with `inline-flex items-center rounded-full
+px-2 py-0.5 text-xs font-medium`.
+
+| Variant | Use for | Light mode | Dark mode |
+|---------|---------|------------|-----------|
+| `neutral` | Default metadata, inactive states, informational labels | `bg-neutral-100 text-neutral-700` | `dark:bg-neutral-800 dark:text-neutral-300` |
+| `ok` | Healthy, connected, successful, or enabled states | `bg-emerald-100 text-emerald-800` | `dark:bg-emerald-950 dark:text-emerald-300` |
+| `warning` | Pending, degraded, or attention-needed states | `bg-amber-100 text-amber-800` | `dark:bg-amber-950 dark:text-amber-300` |
+| `danger` | Failed, destructive, revoked, or blocked states | `bg-rose-100 text-rose-800` | `dark:bg-rose-950 dark:text-rose-300` |
+
+Examples:
+
+```tsx
+Draft
+Active
+Pending
+Revoked
+```
+
+## Button Variants
+
+Use `src/components/Button.tsx` for native button actions. The base shape is a
+rounded pill with `rounded-full px-5 py-2 text-sm font-medium`; disabled buttons
+use `disabled:opacity-50 disabled:cursor-not-allowed`.
+
+| Variant | Use for | Classes |
+|---------|---------|---------|
+| `primary` | Main page action or form submission | `bg-black text-white hover:bg-neutral-800 dark:bg-white dark:text-black dark:hover:bg-neutral-200` |
+| `secondary` | Lower-emphasis actions, filters, and navigation-style controls | `border border-neutral-300 hover:border-neutral-500 dark:border-neutral-700 dark:hover:border-neutral-500` |
+| `danger` | Destructive or irreversible actions | `bg-rose-600 text-white hover:bg-rose-700` |
+
+Examples:
+
+```tsx
+
+
+
+```
+
+## Focus Rings
+
+Interactive controls should keep the focus-visible convention from
+`src/components/Button.tsx`:
+
+```tsx
+focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500
+```
+
+Use this pattern on new custom controls so keyboard focus is visible without
+adding a permanent outline for pointer users.
+
+## Theme Colors
+
+Global foreground and background colors live in `src/app/globals.css`:
+
+```css
+:root {
+ --foreground: #0a0a0a;
+ --background: #fafafa;
+}
+```
+
+The `body` reads those variables, while component-level light and dark pairs are
+expressed with Tailwind classes such as `bg-neutral-100` with
+`dark:bg-neutral-800`. Keep new component colors paired for both light and dark
+mode when the component is visible in both themes.
+
+## Tailwind Scope
+
+`tailwind.config.ts` scans `src/pages`, `src/components`, and `src/app` for
+`js`, `ts`, `jsx`, `tsx`, and `mdx` files. The repo does not currently extend
+Tailwind theme tokens, so new reusable visual decisions should either reuse the
+classes above or be documented when a shared component introduces them.
+
+## Motion
+
+`src/app/globals.css` also honors `prefers-reduced-motion: reduce` by minimizing
+animation and transition duration. Do not rely on animation as the only way to
+communicate state changes.
diff --git a/src/app/quote/Client.tsx b/src/app/quote/Client.tsx
index 4546213..c3b13c7 100644
--- a/src/app/quote/Client.tsx
+++ b/src/app/quote/Client.tsx
@@ -1,7 +1,8 @@
"use client";
-import { useState } from "react";
+import { type FormEvent, useState } from "react";
import type { ApiError } from "@/lib/apiClient";
+import { assetsDiffer, isValidAmount } from "@/lib/quote";
type Quote = {
source_asset: string;
@@ -32,17 +33,25 @@ export default function QuoteClient() {
const [requestId, setRequestId] = useState(null);
const [loading, setLoading] = useState(false);
- const onSubmit = async (event: React.FormEvent) => {
+ const onSubmit = async (event: FormEvent) => {
event.preventDefault();
setError(null);
setRequestId(null);
setQuote(null);
- if (!assetsDiffer(sourceAsset, destAsset)) {
+ const normalizedSourceAsset = normalizeAssetCode(sourceAsset);
+ const normalizedDestAsset = normalizeAssetCode(destAsset);
+ const normalizedAmount = amount.trim();
+
+ if (!normalizedSourceAsset || !normalizedDestAsset) {
+ setError("Asset codes must be 1-12 letters or numbers.");
+ return;
+ }
+ if (!assetsDiffer(normalizedSourceAsset, normalizedDestAsset)) {
setError("Source and destination assets must differ.");
return;
}
- if (!isValidAmount(amount)) {
+ if (!isValidAmount(normalizedAmount)) {
setError("Amount must be a positive integer (base units).");
return;
}