diff --git a/site/app/components/before-after.tsx b/site/app/components/before-after.tsx
index b5042d1..afba28c 100644
--- a/site/app/components/before-after.tsx
+++ b/site/app/components/before-after.tsx
@@ -1,6 +1,6 @@
"use client";
-import { AnimatePresence, motion, useInView } from "motion/react";
+import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { ScrollReveal } from "./scroll-reveal";
@@ -217,7 +217,13 @@ function CodePanel({
))}
-
+
{lines.map((line, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static code lines
@@ -269,6 +275,25 @@ export function BeforeAfter() {
const sectionRef = useRef(null);
const inView = useInView(sectionRef, { once: true, amount: 0.2 });
const [showAfter, setShowAfter] = useState(false);
+ /*
+ * The panel swap is a full-panel scale plus a brightness flash, and it also
+ * fires on its own after 2s — motion the reader never asked for. Under
+ * prefers-reduced-motion the duration drops to 0 so the panels still swap,
+ * they just do not travel.
+ *
+ * useReducedMotion rather than matchMedia in render: matchMedia does not exist
+ * on the server, so reading it during render would throw there and hydrate
+ * mismatched here.
+ *
+ * Two things the hook does NOT do, in motion@12: it does not react to the OS
+ * setting changing mid-session (it snapshots once via useState — upstream's own
+ * TODO), and it returns null on the server rather than a boolean. Neither bites:
+ * null is falsy, so SSR renders the full duration, and `transition` is never
+ * serialized into the DOM, so there is nothing for hydration to disagree about.
+ * If mid-session reactivity is ever wanted, it needs a real listener — do not
+ * assume the hook already provides one.
+ */
+ const shouldReduceMotion = useReducedMotion();
// Auto-flip from Before to After after 2s of being in view
useEffect(() => {
@@ -278,7 +303,7 @@ export function BeforeAfter() {
}, [inView]);
return (
-
+
{/* Section header */}
@@ -304,11 +329,17 @@ export function BeforeAfter() {
{/* Animated toggle pills */}
+ {/*
+ * The pills are a two-state toggle, not navigation: without aria-pressed a
+ * screen reader announces two ordinary buttons and never says which panel
+ * is currently on screen — including after the 2s auto-flip moves it.
+ */}
setShowAfter(false)}
- className={`px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all duration-300 ${
+ className={`focus-ring inline-flex min-h-[44px] items-center justify-center px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all duration-300 ${
!showAfter
? "bg-danger/10 border border-danger/30 text-danger/80 shadow-[0_0_20px_rgba(239,68,68,0.1)]"
: "border border-white/[0.06] text-white/30 hover:text-white/50"
@@ -318,15 +349,20 @@ export function BeforeAfter() {
→
setShowAfter(true)}
- className={`px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all duration-300 ${
+ className={`focus-ring inline-flex min-h-[44px] items-center justify-center px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all duration-300 ${
showAfter
? "bg-ut/10 border border-ut/30 text-ut shadow-[0_0_20px_rgba(52,211,153,0.1)]"
: "border border-white/[0.06] text-white/30 hover:text-white/50"
@@ -346,7 +382,7 @@ export function BeforeAfter() {
initial={{ opacity: 0, scale: 0.97, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 1.02, filter: "brightness(2)" }}
- transition={{ duration: 0.5, ease: "easeInOut" }}
+ transition={{ duration: shouldReduceMotion ? 0 : 0.5, ease: "easeInOut" }}
className="relative rounded-xl border border-danger/20 overflow-hidden"
style={{
background: "rgba(239,68,68,0.02)",
@@ -377,7 +413,7 @@ export function BeforeAfter() {
initial={{ opacity: 0, scale: 0.97, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: -10 }}
- transition={{ duration: 0.4, ease: "easeInOut" }}
+ transition={{ duration: shouldReduceMotion ? 0 : 0.4, ease: "easeInOut" }}
className="relative rounded-xl border border-ut/20"
style={{
background: "rgba(52,211,153,0.03)",
diff --git a/site/app/components/built-for.tsx b/site/app/components/built-for.tsx
index b023745..4a2c318 100644
--- a/site/app/components/built-for.tsx
+++ b/site/app/components/built-for.tsx
@@ -21,7 +21,7 @@ const useCases = [
export function BuiltFor() {
return (
-
+
diff --git a/site/app/components/byok.tsx b/site/app/components/byok.tsx
index 9218989..14a5a3b 100644
--- a/site/app/components/byok.tsx
+++ b/site/app/components/byok.tsx
@@ -13,7 +13,7 @@ const providers = [
export function BYOK() {
return (
-
+
diff --git a/site/app/components/code-example.tsx b/site/app/components/code-example.tsx
index 65a6b89..e376d00 100644
--- a/site/app/components/code-example.tsx
+++ b/site/app/components/code-example.tsx
@@ -4,7 +4,7 @@ import { TypewriterCode } from "./typewriter-code";
export function CodeExample() {
return (
-
+
{/* Left: copy */}
diff --git a/site/app/components/copy-command.tsx b/site/app/components/copy-command.tsx
index 7c6572d..0216797 100644
--- a/site/app/components/copy-command.tsx
+++ b/site/app/components/copy-command.tsx
@@ -27,7 +27,7 @@ export function CopyCommand() {
diff --git a/site/app/components/cta.tsx b/site/app/components/cta.tsx
index 1d6cdcb..262391d 100644
--- a/site/app/components/cta.tsx
+++ b/site/app/components/cta.tsx
@@ -3,7 +3,7 @@ import { ScrollReveal } from "./scroll-reveal";
export function CTA() {
return (
-
+
@@ -28,7 +28,7 @@ export function CTA() {
Start Trusting
@@ -37,7 +37,7 @@ export function CTA() {
href="https://github.com/usertools-ai/usertrust"
target="_blank"
rel="noopener noreferrer"
- className="inline-flex items-center justify-center gap-2 px-6 py-3 min-h-[44px] border border-white/[0.12] rounded-lg text-sm font-medium text-white/80 hover:bg-white/[0.05] hover:text-white hover:border-white/20 hover:shadow-[0_0_20px_rgba(255,255,255,0.04)] transition-all duration-200"
+ className="focus-ring inline-flex items-center justify-center gap-2 px-6 py-3 min-h-[44px] border border-white/[0.12] rounded-lg text-sm font-medium text-white/80 hover:bg-white/[0.05] hover:text-white hover:border-white/20 hover:shadow-[0_0_20px_rgba(255,255,255,0.04)] transition-all duration-200"
>
View on GitHub
diff --git a/site/app/components/features.tsx b/site/app/components/features.tsx
index 4c61afb..e8ccd09 100644
--- a/site/app/components/features.tsx
+++ b/site/app/components/features.tsx
@@ -197,7 +197,7 @@ function SpotlightCard({ children, className }: { children: React.ReactNode; cla
export function Features() {
return (
-
+
{/* Header */}
diff --git a/site/app/components/footer.tsx b/site/app/components/footer.tsx
index bf4e5da..7fd4ece 100644
--- a/site/app/components/footer.tsx
+++ b/site/app/components/footer.tsx
@@ -4,7 +4,7 @@ export function Footer() {
return (
-
+
{/* Brand column */}
@@ -14,39 +14,47 @@ export function Footer() {
- {/* Product */}
-
+ {/*
+ * Link rows are 44px tall for the touch target, and the column carries no
+ * `gap` because those rows already supply the separation — a gap on top of
+ * them stacks two spacings and doubles the footer's height on a phone.
+ *
+ * `items-end`, not `items-center`: `.animated-underline::after` is pinned to
+ * `bottom: -2px` of the anchor box, so centring the label inside a 44px row
+ * strands the hover underline 12px beneath the word it belongs to.
+ */}
+
{/* Resources */}
-
+
Resources
@@ -54,7 +62,7 @@ export function Footer() {
href="https://github.com/usertools-ai/usertrust"
target="_blank"
rel="noopener noreferrer"
- className="animated-underline text-sm text-white/30 hover:text-white/70 transition-colors duration-200"
+ className="focus-ring animated-underline flex min-h-[44px] items-end text-sm text-white/30 hover:text-white/70 transition-colors duration-200"
>
GitHub
@@ -62,7 +70,7 @@ export function Footer() {
href="https://www.npmjs.com/package/usertrust"
target="_blank"
rel="noopener noreferrer"
- className="animated-underline text-sm text-white/30 hover:text-white/70 transition-colors duration-200"
+ className="focus-ring animated-underline flex min-h-[44px] items-end text-sm text-white/30 hover:text-white/70 transition-colors duration-200"
>
npm
@@ -70,14 +78,14 @@ export function Footer() {
href="https://github.com/usertools-ai/usertrust/blob/master/LICENSE"
target="_blank"
rel="noopener noreferrer"
- className="animated-underline text-sm text-white/30 hover:text-white/70 transition-colors duration-200"
+ className="focus-ring animated-underline flex min-h-[44px] items-end text-sm text-white/30 hover:text-white/70 transition-colors duration-200"
>
License
{/* Company */}
-
+
Company
@@ -85,7 +93,7 @@ export function Footer() {
href="https://usertools.ai"
target="_blank"
rel="noopener noreferrer"
- className="animated-underline text-sm text-white/30 hover:text-white/70 transition-colors duration-200"
+ className="focus-ring animated-underline flex min-h-[44px] items-end text-sm text-white/30 hover:text-white/70 transition-colors duration-200"
>
Usertools
@@ -100,7 +108,7 @@ export function Footer() {
href="https://usertools.ai"
target="_blank"
rel="noopener noreferrer"
- className="text-white/30 hover:text-ut transition-colors duration-200"
+ className="focus-ring text-white/30 hover:text-ut transition-colors duration-200"
>
usertools.ai
@@ -108,7 +116,7 @@ export function Footer() {
Back to top ↑
@@ -116,7 +124,7 @@ export function Footer() {
href="https://github.com/usertools-ai/usertrust"
target="_blank"
rel="noopener noreferrer"
- className="flex items-center gap-1.5 text-xs text-white/20 hover:text-white/50 transition-colors duration-200"
+ className="focus-ring inline-flex min-h-[44px] items-center gap-1.5 text-xs text-white/20 hover:text-white/50 transition-colors duration-200"
>
Star on GitHub
diff --git a/site/app/components/governance-receipt.tsx b/site/app/components/governance-receipt.tsx
index 8e5ed96..de6855b 100644
--- a/site/app/components/governance-receipt.tsx
+++ b/site/app/components/governance-receipt.tsx
@@ -205,7 +205,14 @@ export function GovernanceReceipt() {
)}
-
+
{RECEIPT_LINES.map((line, i) => {
const indent = INDENTS[i] ?? 0;
diff --git a/site/app/components/hero.tsx b/site/app/components/hero.tsx
index a990a3c..22bcb3d 100644
--- a/site/app/components/hero.tsx
+++ b/site/app/components/hero.tsx
@@ -56,7 +56,15 @@ export function Hero({ downloads, stars }: { downloads: string; stars: string })
return (
{/* Bliss background — scroll-fades */}
@@ -142,7 +150,7 @@ export function Hero({ downloads, stars }: { downloads: string; stars: string })
>
Start Trusting
@@ -150,7 +158,7 @@ export function Hero({ downloads, stars }: { downloads: string; stars: string })
href="https://github.com/usertools-ai/usertrust"
target="_blank"
rel="noopener noreferrer"
- className="inline-flex items-center justify-center gap-2 px-5 py-2.5 min-h-[44px] bg-white/[0.06] border border-white/10 rounded-lg text-sm font-medium text-white/80 hover:bg-white/[0.10] hover:text-white transition-all duration-150"
+ className="focus-ring inline-flex items-center justify-center gap-2 px-5 py-2.5 min-h-[44px] bg-white/[0.06] border border-white/10 rounded-lg text-sm font-medium text-white/80 hover:bg-white/[0.10] hover:text-white transition-all duration-150"
>
View on GitHub
diff --git a/site/app/components/how-it-works.tsx b/site/app/components/how-it-works.tsx
index 366ec65..f5ba379 100644
--- a/site/app/components/how-it-works.tsx
+++ b/site/app/components/how-it-works.tsx
@@ -70,7 +70,7 @@ function ArchAnnotation({
/* ------------------------------------------------------------------ */
export function HowItWorks() {
return (
-
+
{/* Header */}
diff --git a/site/app/components/nav.tsx b/site/app/components/nav.tsx
index 7f92412..b4a3748 100644
--- a/site/app/components/nav.tsx
+++ b/site/app/components/nav.tsx
@@ -51,6 +51,53 @@ export function Nav() {
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
+ /*
+ * Lock the page behind the open menu. Without this the body scrolls under the
+ * dropdown on iOS and the menu appears to drift; the `[overscroll-behavior:contain]`
+ * on the dropdown stops the scroll chaining back to the body once the menu's own
+ * list hits its end. The previous inline value is restored rather than cleared, so
+ * a lock owned by something else outlives this one.
+ */
+ useEffect(() => {
+ if (!open) return;
+ const previous = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = previous;
+ };
+ }, [open]);
+
+ /*
+ * Close on the way up to desktop. The dropdown is `md:hidden`, so crossing the
+ * breakpoint while open hides the menu but leaves `open` true — and with it the body
+ * scroll lock, on a viewport with no visible way to release it.
+ */
+ useEffect(() => {
+ const mq = window.matchMedia("(min-width: 768px)");
+ const onChange = (e: MediaQueryListEvent) => {
+ if (e.matches) setOpen(false);
+ };
+ mq.addEventListener("change", onChange);
+ return () => mq.removeEventListener("change", onChange);
+ }, []);
+
+ /*
+ * Escape closes, and focus returns to the button that opened it — otherwise a
+ * keyboard user who dismisses the menu is left with focus on a removed node and
+ * tabbing restarts from the top of the document.
+ */
+ useEffect(() => {
+ if (!open) return;
+ function handleKey(e: KeyboardEvent) {
+ if (e.key === "Escape") {
+ setOpen(false);
+ buttonRef.current?.focus();
+ }
+ }
+ document.addEventListener("keydown", handleKey);
+ return () => document.removeEventListener("keydown", handleKey);
+ }, [open]);
+
const links = [
{ href: "#code", label: "Code" },
{ href: "#features", label: "Features" },
@@ -66,10 +113,10 @@ export function Nav() {
: "bg-brand-bg/80 backdrop-blur-[16px] border-white/[0.06]"
}`}
>
-
+
- {/* Mobile dropdown */}
+ {/*
+ * Mobile dropdown. No `pb-4` here on purpose: `.safe-bottom` owns the bottom
+ * padding with a 1rem floor, and stacking both just doubles the padding —
+ * `.safe-bottom` is unlayered while Tailwind's `pb-4` sits in `@layer utilities`,
+ * so the safe-area value wins the cascade either way. `max-h` plus
+ * `overflow-y-auto` keep the menu reachable when the viewport is short enough that
+ * four 44px links and the header exceed it, and `[overscroll-behavior:contain]` is
+ * what stops that inner scroll chaining out to the locked body.
+ */}
{open && (
-
+
{allChars.current.map((c, i) =>
c.char === "\n" ? : {c.char} ,
@@ -182,7 +188,13 @@ export function TypewriterCode() {
))}
-
+
{rendered.map((c, i) =>
c.char === "\n" ? (
diff --git a/site/app/docs/layout.tsx b/site/app/docs/layout.tsx
index f21fc5c..cd64391 100644
--- a/site/app/docs/layout.tsx
+++ b/site/app/docs/layout.tsx
@@ -1,9 +1,35 @@
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import { RootProvider } from "fumadocs-ui/provider/next";
+import type { Viewport } from "next";
import type { ReactNode } from "react";
import { baseOptions } from "@/lib/layout.shared";
import { source } from "@/lib/source";
+/*
+ * Docs opts out of the root layout's `viewportFit: "cover"`.
+ *
+ * `cover` exists for the marketing page, whose hero is sized in `dvh` and needs
+ * to paint under the iOS toolbar. Docs gains nothing from it and pays for it:
+ * fumadocs' navbar is `position: fixed`, so the `body` safe-area padding in
+ * globals.css cannot reach it, and fumadocs ships no safe-area handling of its
+ * own — in landscape on a notched device its logo and search control would sit
+ * under the cutout. Reverting to the default `auto` makes the browser inset the
+ * layout viewport itself, which is the correct behaviour for a page with no
+ * edge-to-edge art direction.
+ *
+ * `viewportFit` must be set to `"auto"` explicitly, not omitted: Next merges a
+ * nested viewport export into the parent's field by field, so an absent field
+ * inherits rather than clears. Verified in the emitted meta tag, which is the
+ * only way to tell the two apart.
+ */
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+ viewportFit: "auto",
+ themeColor: "#0a0a1a",
+ colorScheme: "dark",
+};
+
export default function Layout({ children }: { children: ReactNode }) {
return (