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 Logo - Opndrive -
- - {/* Hamburger Menu Button */} - -
-
- - {/* Mobile Menu Dropdown */} - {isMobileMenuOpen && showMobileNav && ( -
-
- {/* Navigation Links */} -
- {navItems.map((item, index) => ( - - ))} -
- - {/* Divider */} -
- - {/* Actions Section */} -
- {/* GitHub Link */} - - - GitHub - {stars !== null && ( - {stars.toLocaleString()} - )} - - - {/* Discord Community - taps open the Bento sheet */} - setIsMobileMenuOpen(false)} - /> - - {/* Theme Toggle */} -
- Theme - -
- - {/* Get Started Button */} - -
-
-
- )} -
- - {/* Overlay to close menu when clicking outside */} - {isMobileMenuOpen && showMobileNav && ( -
setIsMobileMenuOpen(false)} - /> - )} - - - {/* Add top padding after hero section for mobile navbar */} -
- - - - - - -
-
+ <> +