From e286dd7f88d795a1614e0922f6270137d207a0c0 Mon Sep 17 00:00:00 2001 From: Cam Date: Tue, 28 Jul 2026 06:52:09 -0400 Subject: [PATCH 1/3] feat(site): mobile viewport and safe-area foundations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `viewport-fit=cover` is what makes `env(safe-area-inset-*)` resolve to real values on iOS; without it they are 0px and every safe-area rule below silently does nothing. `themeColor` and `colorScheme` go on the `Viewport` export, not `metadata` — both are `@deprecated` there, pointing at exactly this export. Docs opts back out to `viewportFit: "auto"`. It has no edge-to-edge art direction to gain from `cover`, and fumadocs' navbar is `position: fixed` with no safe-area handling of its own, so it would sit under the cutout in landscape with nothing able to pad it. The field is set explicitly rather than omitted: a nested viewport export merges into the parent field by field, so leaving it out inherits `cover` instead of clearing it — visible only in the emitted meta tag. `safe-x` is registered with `@utility`, not written as a bare class. A bare class is unlayered, and unlayered beats `@layer utilities` regardless of specificity, so it would have silently overridden any `px-*` on the same element — a trap for the next edit rather than a composable utility. An earlier revision of this put the horizontal inset on `body`. That is wrong: a section's full-bleed decoration is `absolute inset-0`, which resolves against the padding box and still reaches both screen edges, whereas padding `body` shrinks the box the section is laid out in and shows the page background as bars beside the hero image — on exactly the devices the inset exists for. `::-webkit-scrollbar` gains a `height`. `width` styles the vertical bar only, so the newly horizontally-scrollable code panels would otherwise get the ~15px platform default on Windows and Linux. macOS overlay scrollbars hide it, so it does not reproduce locally. Tailwind v4's preflight already emits `-webkit-text-size-adjust: 100%` and `-webkit-tap-highlight-color: transparent` on `html`, so neither is restated here. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Cam --- site/app/docs/layout.tsx | 26 +++++++++++++++++ site/app/globals.css | 62 +++++++++++++++++++++++++++++++++++++++- site/app/layout.tsx | 19 +++++++++++- 3 files changed, 105 insertions(+), 2 deletions(-) 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 ( Date: Tue, 28 Jul 2026 06:52:28 -0400 Subject: [PATCH 2/3] feat(site): visible focus, 44px targets, and mobile-safe layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site had zero `focus-visible` occurrences: every link and button was an invisible focus stop for a keyboard or switch-control user. Every interactive element in `site/app/**` now carries `.focus-ring` — emerald on the dark background at ~9:1, with a 2px offset so the ring stays readable even on the `bg-ut` primary buttons, where a flush ring would be emerald on emerald. Touch targets below 44px are brought up in the same edit, since they land on the same class strings: the nav hamburger, the nav links, the before/after toggles. The nav active-dot moves from `-bottom-1.5` to `bottom-1.5` as a consequence — the link box is now 44px tall around a 20px text line, so an offset measured outside the box lands 12px below the label instead of under it. Nav menu: the body is locked while the dropdown is open, restoring the previous inline value rather than clearing it, so a lock owned by something else outlives this one. Escape closes and returns focus to the hamburger, otherwise a keyboard user is left focused on a removed node and tabbing restarts from the top of the document. Crossing up to the `md` breakpoint closes it too — the dropdown is `md:hidden`, so it would otherwise hide while leaving the scroll lock held on a viewport with no visible way to release it. `aria-controls` is emitted only while the menu exists; as a constant it would be an IDREF resolving to nothing, which validators flag and assistive tech ignores. Hero is `100dvh`, not `100vh`: `vh` measures the viewport without the retracting iOS toolbar, so the hero overflowed by the toolbar's height and the page jerked as it hid. Top padding moves to `dvh` in step — mixing the two sizes the box against one viewport and its padding against another. Code panels scroll horizontally instead of clipping. The rounded shells keep `overflow-hidden`, so the scroll container is the `
` itself; at 375px the
longest lines were previously unreachable rather than merely off-screen.

Before/after toggle gains `aria-pressed` and honours `prefers-reduced-motion`
via `useReducedMotion`. Note the hook snapshots once at mount and returns null
on the server in motion@12 — harmless here, and recorded at the call site so
nobody assumes it already listens for changes.

Sections take the horizontal safe-area inset via `safe-x` in place of `px-6`;
it resolves to the same 1.5rem wherever there is no cutout.

Co-Authored-By: Claude Opus 5 (1M context) 
Signed-off-by: Cam 
---
 site/app/components/before-after.tsx       | 52 +++++++++++--
 site/app/components/built-for.tsx          |  2 +-
 site/app/components/byok.tsx               |  2 +-
 site/app/components/code-example.tsx       |  2 +-
 site/app/components/copy-command.tsx       |  2 +-
 site/app/components/cta.tsx                |  6 +-
 site/app/components/features.tsx           |  2 +-
 site/app/components/footer.tsx             | 40 ++++++----
 site/app/components/governance-receipt.tsx |  9 ++-
 site/app/components/hero.tsx               |  9 ++-
 site/app/components/how-it-works.tsx       |  2 +-
 site/app/components/nav.tsx                | 89 +++++++++++++++++++---
 site/app/components/typewriter-code.tsx    | 16 +++-
 13 files changed, 185 insertions(+), 48 deletions(-)

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. + */}