diff --git a/.changeset/landing-page-seo-and-rotation.md b/.changeset/landing-page-seo-and-rotation.md
new file mode 100644
index 00000000..6e1b9e6a
--- /dev/null
+++ b/.changeset/landing-page-seo-and-rotation.md
@@ -0,0 +1,40 @@
+---
+'frontend': patch
+---
+
+Put the FAQ answers in the page, and stop the feature list rotating at readers
+
+The eight FAQ answers were mounted only once their question was clicked, so the
+most substantial writing on the landing page - which S3 permissions are needed,
+where the credentials are actually stored - reached a crawler as nothing at all.
+They are in the markup now, collapsed by a `0fr` grid row rather than dropped
+from the tree, and the SSR test asserts every answer is really there. The
+accordion also has `aria-expanded` and `aria-controls` it never had, and each
+question is a heading wrapping its button rather than a heading buried inside
+one, so the eight questions form an outline a reader can navigate.
+
+`/` is now a server shell around the client page, which is what lets it emit
+FAQPage structured data built from the same `faqData` the section renders -
+Google requires the markup to describe what a visitor can see, and generating
+both from one array is what keeps that true when a question is edited. The route
+was already server-rendered on demand, so the shell costs nothing new.
+
+`verification` no longer ships `content="your-google-verification-code"` on
+every page: a claim to own the site backed by a token that verifies nothing. It
+reads `GOOGLE_SITE_VERIFICATION` from the server environment and is left off
+when unset.
+
+Both feature sections rotated every six seconds for as long as the tab was open.
+Narrower than `lg` the image column beside the list is `hidden`, so the timer's
+only effect was to swap the paragraph a reader was in the middle of - and the
+pause is bound to hover, which a touch device never fires, so on a phone there
+was no way to stop it. Rotation is now gated on the viewport being wide enough
+to show the image and on the section being on screen at all. The 500ms crossfade
+it promised never ran either: `key` remounts the image, and a brand new element
+has no previous opacity to transition from, so it was a hard cut. It fades in
+now.
+
+Three star counters mount on the landing page in the same tick, and the GitHub
+cache is only written once a response lands - so all three missed it and all
+three fetched, against a limit of sixty requests an hour for an unauthenticated
+IP. Callers now share a request that is already running.
diff --git a/.changeset/navbar-handover-jerk.md b/.changeset/navbar-handover-jerk.md
new file mode 100644
index 00000000..52c6b1fa
--- /dev/null
+++ b/.changeset/navbar-handover-jerk.md
@@ -0,0 +1,104 @@
+---
+'frontend': patch
+---
+
+Hand the landing page's two navbars over without the jerk
+
+Scrolling out of the hero made the page lurch. Three separate things fired on
+that one scroll position, and one of them moved the page: the wrapper holding
+everything below the hero carried
+`style={{ paddingTop: showMobileNav ? '3.5rem' : '0' }}`, so 56px appeared under
+the hero at the exact moment the bar faded in and everything below it dropped.
+An inline style also beats the `lg:pt-0` beside it, so desktop - which has no
+mobile bar to make room for - got the same shove. The room is held open
+permanently now.
+
+Held open, it is not hidden until the bar covers it, which is what this said
+first. The bar only arrives once the hero is entirely past, but the reserved
+room enters the viewport from the bottom edge a whole screen before that - and
+as padding on a transparent wrapper what showed through it was `body`, which is
+`--secondary`, a lighter band than the `--background` on either side of it. On a
+phone it read as a grey seam sliding up the page ahead of the navbar, and in
+light mode as a blue-grey one across white.
+
+So the room is a strip of its own now, painted `--background` like its
+neighbours and carrying the divider artwork - the filaments running into the
+mark - so that the same 56px reads as the deliberate join between hero and page
+that a reader is about to scroll through. It is exactly the height of the bar
+that lands on it, and gone at `lg`, where there is no bar to make room for and
+the hero still meets the next section directly.
+
+The artwork needed treating rather than dropping in. The file is not the
+transparent PNG it appears to be: every pixel is opaque and what should have
+been transparency was flattened to a flat grey at luminance 43, so as an image
+it would have replaced one wrong-coloured band with another. A `contrast()`
+lands that flat field on black and `screen` composites black as no change, so
+the page's own background is what shows between the filaments; on a light page
+the pair inverts to `contrast()` onto white and `multiply`. A genuinely
+transparent export would let both filters come off.
+
+The floating desktop navbar mounted on the way past, too, which built thirty-odd
+nodes, a backdrop-filter layer to rasterise and a star count that lands a tick
+later and re-centres the pill, all on the frame the browser was already busy
+scrolling - and then ran two `setTimeout`s to fade in what it had just built. It
+is now mounted with the rest of the page and revealed with a composited fade,
+the same way the mobile bar always was. It fades out to `invisible` rather than
+just transparent, so a bar nobody can see is not still tabbable.
+
+Both bars now take their cue from one `IntersectionObserver` on the hero's
+sentinel, which the page owns and passes down. That replaces two `scroll`
+listeners that each called `getBoundingClientRect()` on every event, forcing a
+synchronous layout per frame, one of them re-registering itself on every toggle.
+It also means the bars hand off at the same pixel: the floating nav used to
+appear while the hero was still most of the screen, which is what made the page
+look like it had two navbars at once.
+
+The blog navbar ran a third copy of that listener, tracking a sentinel into two
+state values that nothing rendered. It is gone, along with the sentinel it
+watched.
+
+The Discord card in the hero navbar opened off the top of the screen. That
+navbar asks for a card above it, which is right where it starts - near the
+bottom of the viewport - but it scrolls with the page, and a reader who has
+pushed it near the top has no room above it left. `placement` is a preference
+now rather than an outcome: the card is measured against the space on each side
+of its trigger when it opens, and moves to the other side when the requested one
+cannot hold it and the other is roomier.
+
+The connector squiggle beside that card is drawn above the chrome it comes out
+of, so it wins every overlap - and out of a navbar, where the trigger is a 20px
+icon in a packed row, what it won was the theme toggle sitting next to it. It is
+measured against its neighbours now and left out where there is something within
+its reach, which leaves the card to stand on its own in both navbars. Out of the
+sidebar, where the row is full width and the arrow leaves into open page, it is
+unchanged.
+
+Three more things about the mobile bar, all of them visible on a phone.
+
+It could fail to appear at all. An IntersectionObserver only reports when its
+target's intersecting state changes, and against the bare viewport a sentinel
+below the fold and a sentinel above it are both "not intersecting" - so a jump
+straight from one to the other never reported anything and the bar stayed hidden
+on a page scrolled well past the hero. A `#faq` deep link does that, so does the
+scroll position a browser restores on a back navigation, and so does any hero
+taller than the viewport, which is every phone once the address bar has taken
+its cut. The observer's root is extended downward past any page length now, so
+the sentinel intersects from load until it leaves over the top edge and the one
+crossing that matters is a real transition.
+
+Every link in the menu landed its own destination underneath the bar. Both
+navbars are fixed at the top and `scrollIntoView` puts the target's top edge at
+the top of the viewport, which is exactly where they are, so "Curious about
+Opndrive?" arrived cut in half. The sections carry `scroll-mt` matching each
+navbar's height, so the browser stops short by the bar it is scrolling under.
+
+And the open menu could not be scrolled. Six links, three action rows and a
+button come to around 620px with no cap and no overflow, which clears a tall
+phone and does not clear a short one once the browser's chrome has taken its
+cut, leaving the "Get Started" button at the bottom off screen and unreachable.
+It is capped to what is left of the viewport below the bar, in `dvh` so that the
+address bar is counted, and scrolls past that.
+
+The bar's own bottom hairline is a shadow rather than `border-b`, so that it is
+exactly the `h-14 sm:h-16` it says it is - as a border it measured a pixel
+taller, and the strip reserving room for it is sized off the stated height.
diff --git a/frontend/public/hero-divider.png b/frontend/public/hero-divider.png
new file mode 100644
index 00000000..ce324ff6
Binary files /dev/null and b/frontend/public/hero-divider.png differ
diff --git a/frontend/src/app/blog/[slug]/page.tsx b/frontend/src/app/blog/[slug]/page.tsx
index 395a25eb..814b9188 100644
--- a/frontend/src/app/blog/[slug]/page.tsx
+++ b/frontend/src/app/blog/[slug]/page.tsx
@@ -213,7 +213,7 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
{/* Static Header Spacer */}
-
+
{/* Article Container */}
diff --git a/frontend/src/app/blog/page.tsx b/frontend/src/app/blog/page.tsx
index fb469a58..81ddc75d 100644
--- a/frontend/src/app/blog/page.tsx
+++ b/frontend/src/app/blog/page.tsx
@@ -129,7 +129,7 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
{/* Static Header Spacer */}
-
+
{/* Hero Section */}
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 5f1efad4..7e65d97b 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -359,6 +359,72 @@ body {
font-family: Arial, Helvetica, sans-serif;
}
+/**
+ * The artwork in the strip that joins the hero to the page below it.
+ *
+ * That strip is the room the fixed mobile bar drops into, so its height is the
+ * bar's and cannot grow. The source is 3.5:1 in a box nearer 7:1, so `cover`
+ * crops to the middle band - which is where the filaments run and where the
+ * mark sits - rather than letterboxing it.
+ *
+ * The file is not the transparent PNG it looks like: every pixel is opaque, and
+ * what should have been transparency was flattened into a flat grey at
+ * luminance 43, with the drawing itself only reaching about 110. Dropped in as
+ * an image it would have replaced the wrong-coloured band this strip exists to
+ * remove with another one, a shade lighter still.
+ *
+ * So the flat grey is turned back into nothing. `contrast(1.62)` pivots on 128
+ * and lands the whole flat field, which wanders up to 47, at or under 0 - black - and `screen` composites black as no
+ * change at all, so the page's own `--background` is what shows between the
+ * filaments. `brightness` afterwards only lifts what survived; it cannot raise
+ * black off zero, so the background stays put however far it is pushed. Replace
+ * the file with a genuinely transparent export and these two filters come off
+ * together.
+ *
+ * The mask is what stops the rest looking like a pasted-in rectangle. It fades
+ * hard on the vertical, where the strip's own edges would otherwise be a pair
+ * of seams across the page, and only gently on the horizontal, where the
+ * artwork runs full-bleed off both sides of the screen the way it is drawn to.
+ *
+ * `-webkit-mask-image` alongside the standard property for Safari, which only
+ * took the unprefixed one in 15.4 and is still worth carrying on a phone-only
+ * decoration.
+ */
+.hero-divider-art {
+ background-image: url('/hero-divider.png');
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ filter: contrast(1.62) brightness(1.5);
+ mix-blend-mode: screen;
+ opacity: 0.85;
+ -webkit-mask-image: radial-gradient(130% 70% at 50% 50%, #000 10%, transparent 80%);
+ mask-image: radial-gradient(130% 70% at 50% 50%, #000 10%, transparent 80%);
+}
+
+/**
+ * The same drawing against a light page.
+ *
+ * Inverting first puts the flat background at 212 and the artwork below it, so
+ * the pairing flips with it: `contrast` lands 212 on white instead of black,
+ * and `multiply` is the mode that composites white as no change. The filaments
+ * come out dark on white, reading the same way round as they do on the dark
+ * page rather than as a photographic negative of it.
+ */
+:root[data-theme='light'] .hero-divider-art {
+ filter: invert(1) contrast(1.62);
+ mix-blend-mode: multiply;
+ opacity: 0.6;
+}
+
+@media (prefers-color-scheme: light) {
+ :root:not([data-theme='dark']) .hero-divider-art {
+ filter: invert(1) contrast(1.62);
+ mix-blend-mode: multiply;
+ opacity: 0.6;
+ }
+}
+
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--secondary-foreground) transparent;
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index b13a3c34..8b07ea07 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -76,9 +76,18 @@ export const metadata: Metadata = {
'max-snippet': -1,
},
},
- verification: {
- google: 'your-google-verification-code', // Add your Google Search Console verification code
- },
+ /**
+ * Only emitted once a real code is configured.
+ *
+ * The placeholder that stood here put
+ * ``
+ * on every page: a claim to own the site, backed by a token that verifies
+ * nothing. Read from the server environment, so it never reaches the client
+ * bundle, and left off entirely when unset.
+ */
+ ...(process.env.GOOGLE_SITE_VERIFICATION
+ ? { verification: { google: process.env.GOOGLE_SITE_VERIFICATION } }
+ : {}),
};
export default async function RootLayout({
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 0a1d42eb..17954a2e 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -1,214 +1,41 @@
-'use client';
-
-import { useState, useEffect } from 'react';
-import { useRouter } from 'next/navigation';
-import { FaGithub } from 'react-icons/fa';
-import { Menu, X } from 'lucide-react';
-import HeroSection from '@/features/landing-page/components/hero-section';
-import Navbar from '@/features/landing-page/components/navbar';
-import { DiscordCommunityLink } from '@/shared/components/discord/discord-community-link';
-import FeaturesSection from '@/features/landing-page/components/feature-section';
-import WorkSmarterSection from '@/features/landing-page/components/work-smarter-section';
-import FAQSection from '@/features/landing-page/components/faq-section';
-import CTASection from '@/features/landing-page/components/cta-section';
-import ThemeToggleCustom from '@/shared/components/layout/ThemeToggleCustom';
-import { SiteFooter } from '@/shared/components/layout/site-footer';
-import { useOpndriveStars } from '@/hooks/use-github-stars';
-import { useAuth } from '@/hooks/use-auth';
-import { DOCS_URL } from '@/config/links';
-
-const navItems = [
- { label: 'Home', href: '#hero' },
- { label: 'Features', href: '#features' },
- { label: 'Tools', href: '#tools' },
- { label: 'FAQ', href: '#faq' },
- { label: 'Docs', href: DOCS_URL },
- { label: 'Get Started', href: '#get-started' },
-];
-
-export default function LandingPage() {
- const router = useRouter();
- const { userCreds } = useAuth();
- const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
- const [showMobileNav, setShowMobileNav] = useState(false);
-
- // Use custom hook for GitHub stars
- const { stars } = useOpndriveStars();
-
- // Simple scroll detection to show navbar after hero section
- useEffect(() => {
- const handleScroll = () => {
- const heroSection = document.getElementById('hero');
- if (heroSection) {
- const heroRect = heroSection.getBoundingClientRect();
- const shouldShowNav = heroRect.bottom <= 0; // Show when hero is completely scrolled past
- setShowMobileNav(shouldShowNav);
- }
- };
-
- window.addEventListener('scroll', handleScroll);
- handleScroll(); // Check initial state
-
- return () => window.removeEventListener('scroll', handleScroll);
- }, []);
-
- /**
- * Where the main call to action goes, and what it admits to doing.
- *
- * It has always sent a visitor with a connected bucket to their drive rather
- * than back through the connect flow - it just did so while still reading
- * "Get Started", so the one button whose label people rely on was the one
- * that did not describe itself. The label now names the destination.
- *
- * The session comes from the auth context rather than a second, hand-copied
- * read of the `s3_user_session` key, which is the sort of duplicate that
- * survives a rename of the original.
- *
- * There is deliberately no loading state. This page is public now, so the
- * server renders it before any session has been looked for - and a hero
- * button reading "Loading..." is what a crawler would have indexed as the
- * call to action. It says "Get Started" and goes to /connect until a session
- * turns up, which is the right answer for almost everyone who lands here and
- * a harmless one for the rest: /connect offers them the way back in.
- */
- const hasSession = userCreds !== null;
- const ctaLabel = hasSession ? 'Go to Dashboard' : 'Get Started';
-
- const handleGetStarted = () => {
- router.push(hasSession ? '/dashboard' : '/connect');
- };
-
- const handleNavClick = (href: string) => {
- setIsMobileMenuOpen(false);
- if (href.startsWith('http')) {
- window.location.href = href;
- return;
- }
- if (!href.startsWith('#')) {
- router.push(href);
- return;
- }
- const element = document.querySelector(href);
- if (element) {
- element.scrollIntoView({ behavior: 'smooth' });
- }
- };
+import { headers } from 'next/headers';
+import LandingPage from '@/features/landing-page/components/landing-page';
+import { faqData } from '@/features/landing-page/config/faq-section';
+
+/**
+ * A server shell around the landing page, which is a client component and so
+ * cannot do either of the two things below.
+ *
+ * The FAQ structured data is built from the same `faqData` the section renders,
+ * because Google requires the markup to describe what a visitor can actually
+ * see - generating both from one array is what keeps that true when a question
+ * is edited. And the nonce, which every inline script here carries, can only be
+ * read on the server.
+ *
+ * The route was already server-rendered on demand before this - the root layout
+ * reads `headers()` - so the shell costs nothing it was not already paying.
+ */
+export default async function Page() {
+ const nonce = (await headers()).get('x-nonce') ?? undefined;
return (
-
- {/* Simple Mobile Navbar - Hidden in hero, visible after */}
-
-
-
- {/* Logo */}
-
-
- Opndrive
-
-
- {/* Hamburger Menu Button */}
-
-
-
-
- {/* Mobile Menu Dropdown */}
- {isMobileMenuOpen && showMobileNav && (
-