@@ -138,53 +153,68 @@ export function ShortenForm() {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={ease}
- className="card p-4 sm:p-5"
+ className="card p-3 sm:p-4"
>
- {/* URL field */}
-
- Long URL
-
-
{
- setUrl(e.target.value);
- if (errorInput === "url") clearError();
- }}
- placeholder="https://example.com/a/very/long/path"
- autoComplete="off"
- spellCheck={false}
- className={`h-12 w-full rounded-xl border bg-surface-2 px-3.5 font-mono text-sm text-ink outline-none transition-colors placeholder:font-sans placeholder:text-muted ${
- errorInput === "url" ? "border-red-400" : "border-line focus:border-[var(--ink)]"
- }`}
- />
+ {/* Primary action: paste, shorten. Stacks on mobile, one row on desktop. */}
+
+
{
+ setUrl(e.target.value);
+ if (errorInput === "url") clearError();
+ }}
+ placeholder="Paste a long link"
+ aria-label="Long URL"
+ type="url"
+ inputMode="url"
+ autoComplete="off"
+ autoCapitalize="off"
+ spellCheck={false}
+ // 16px on mobile: anything smaller makes iOS Safari zoom on focus.
+ // flex-1 only from sm — in the mobile column it would set the
+ // flex-basis on the height axis and collapse the field.
+ className={`field h-12 w-full min-w-0 shrink-0 px-3.5 font-mono text-[16px] placeholder:font-sans placeholder:text-muted sm:flex-1 sm:text-sm ${
+ errorInput === "url" ? "field-error" : ""
+ }`}
+ />
+
+ {busy ? (
+
+ ) : (
+ <>
+ Shorten
+
+ >
+ )}
+
+
- {/* Mode selector */}
-
-
-
Link style
-
+ {/* Link style */}
+
+
+
+ {hints[mode]}
+
{getShortDomain()}/{preview}
-
{/* Custom alias */}
{mode === "custom" && (
-
+
-
+
{getShortDomain()}/
@@ -202,91 +235,58 @@ export function ShortenForm() {
{/* PIN protection */}
-
- {user ? (
- <>
-
setPinOn((v) => !v)}
- className="focusable flex w-full items-center gap-3 rounded-lg text-left"
- >
-
-
-
-
-
- Protect with a 6-digit PIN
-
-
- Visitors enter it before redirect
-
-
-
-
- {pinOn && (
-
-
-
- )}
-
- >
- ) : (
-
-
-
- navigate("/login")}
- className="focusable rounded font-medium text-ink underline underline-offset-2"
- >
- Sign in
- {" "}
- for custom aliases and PIN protection.
-
-
- )}
-
+ {user ? (
+ <>
+ setPinOn((v) => !v)}
+ aria-pressed={pinOn}
+ className={`focusable mt-3 inline-flex h-9 items-center gap-2 rounded-full border px-3 text-[13px] font-medium transition-colors ${
+ pinOn
+ ? "grad-tint border-transparent text-accent"
+ : "border-line text-muted hover:text-ink"
+ }`}
+ >
+
+ {pinOn ? "PIN protected" : "Add a PIN"}
+
+
+ {pinOn && (
+
+
+
+ )}
+
+ >
+ ) : (
+
+ navigate("/login")}
+ className="focusable rounded font-medium text-ink underline underline-offset-2"
+ >
+ Sign in
+ {" "}
+ for custom aliases and PIN protection.
+
+ )}
{/* Error */}
{error && (
{error}
)}
-
- {/* Submit */}
-
- {busy ? (
-
- ) : (
- "Shorten link"
- )}
-
)}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
index 9d9c838..66a09cc 100644
--- a/src/components/ui/Button.tsx
+++ b/src/components/ui/Button.tsx
@@ -18,9 +18,9 @@ const sizes: Record = {
};
const variants: Record = {
- // solid ink — the one strong action, inverts with the theme
- primary:
- "bg-[var(--ink)] text-[var(--bg)] font-medium hover:opacity-90",
+ // brand gradient — the one strong action per view
+ primary: "grad-bg font-medium hover:opacity-90",
+ // solid ink, for when a neutral strong action reads better
dark: "bg-[var(--ink)] text-[var(--bg)] font-medium hover:opacity-90",
outline:
"bg-transparent text-ink font-medium border border-line hover:bg-surface-2",
diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx
index 82ca76e..fd85168 100644
--- a/src/context/ThemeContext.tsx
+++ b/src/context/ThemeContext.tsx
@@ -30,7 +30,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
/* ignore */
}
const meta = document.querySelector('meta[name="theme-color"]');
- if (meta) meta.setAttribute("content", theme === "dark" ? "#0e100a" : "#f4f2ea");
+ // Must track --bg in index.css.
+ if (meta) meta.setAttribute("content", theme === "dark" ? "#09090b" : "#fbfbfa");
}, [theme]);
const toggle = useCallback(
diff --git a/src/index.css b/src/index.css
index cc972f3..281e8ab 100644
--- a/src/index.css
+++ b/src/index.css
@@ -14,6 +14,15 @@
--accent: #2563eb;
--accent-soft: rgba(37, 99, 235, 0.1);
--accent-ink: #ffffff;
+
+ /* One brand gradient, used at full strength on the hero + CTAs and as a soft
+ tint everywhere else. Both stops clear 4.5:1 against --grad-ink. */
+ --grad-from: #2563eb;
+ --grad-to: #7c3aed;
+ --grad-ink: #ffffff;
+ --grad-soft: linear-gradient(135deg, rgba(37, 99, 235, 0.1), rgba(124, 58, 237, 0.1));
+ --glow-1: rgba(37, 99, 235, 0.11);
+ --glow-2: rgba(124, 58, 237, 0.1);
}
.dark {
@@ -28,6 +37,14 @@
--accent: #60a5fa;
--accent-soft: rgba(96, 165, 250, 0.14);
--accent-ink: #09090b;
+
+ /* Lighter stops on near-black, so --grad-ink flips to dark. */
+ --grad-from: #60a5fa;
+ --grad-to: #a78bfa;
+ --grad-ink: #09090b;
+ --grad-soft: linear-gradient(135deg, rgba(96, 165, 250, 0.14), rgba(167, 139, 250, 0.14));
+ --glow-1: rgba(96, 165, 250, 0.13);
+ --glow-2: rgba(167, 139, 250, 0.12);
}
* {
@@ -47,6 +64,11 @@ body {
text-rendering: optimizeLegibility;
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
+ /* Ambient colour that never sits under text — fixed so it doesn't scroll. */
+ background-image: radial-gradient(50% 32% at 8% 0%, var(--glow-1), transparent 70%),
+ radial-gradient(45% 30% at 96% 6%, var(--glow-2), transparent 70%);
+ background-attachment: fixed;
+ background-repeat: no-repeat;
}
/* Semantic token utilities (kept stable so components stay theme-driven) */
@@ -65,6 +87,45 @@ body {
border-radius: 1rem;
}
+.grad-bg {
+ background-image: linear-gradient(135deg, var(--grad-from), var(--grad-to));
+ color: var(--grad-ink);
+}
+
+/* Soft tint over a surface — background-image layers on top of the colour. */
+.grad-tint {
+ background-color: var(--surface);
+ background-image: var(--grad-soft);
+}
+
+.grad-text {
+ background-image: linear-gradient(135deg, var(--grad-from), var(--grad-to));
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+}
+
+/* One field look for every input + input wrapper. */
+.field {
+ background-color: var(--surface-2);
+ border: 1px solid var(--line);
+ border-radius: 0.75rem;
+ color: var(--ink);
+ outline: none;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+.field:focus,
+.field:focus-within {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+.field-error,
+.field-error:focus,
+.field-error:focus-within {
+ border-color: #ef4444;
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.16);
+}
+
::selection {
background: var(--accent);
color: var(--accent-ink);
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 1b5c88f..697daee 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -2,50 +2,32 @@ import { motion } from "framer-motion";
import { ShortenForm } from "../components/ShortenForm";
import { useAuth } from "../context/AuthContext";
-const fade = {
- hidden: { opacity: 0, y: 10 },
- show: (i: number) => ({
- opacity: 1,
- y: 0,
- transition: { delay: i * 0.05, duration: 0.3, ease: "easeOut" },
- }),
-};
-
export function Home() {
const { user } = useAuth();
return (
-
- {/* Compact hero on mobile so the input is reachable; full size on desktop. */}
-
- {user ? `Welcome back, ${user.name.split(" ")[0]}` : "No account needed to start"}
-
+
+
+ Long links,{" "}
+ {/* pb-[0.1em] so descenders aren't clipped by the background-clip box */}
+ made tiny.
+
-
- Long links, made tiny.
-
+
+ {user
+ ? `Welcome back, ${user.name.split(" ")[0]}.`
+ : "Paste a link, get a short one. No account needed."}
+
-
+
-
-
+
+
);
}
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index b0eb660..98ab9e8 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -36,7 +36,7 @@ export function Login() {
transition={{ duration: 0.25, ease: "easeOut" }}
className="card w-full p-6 sm:p-7"
>
-
+