Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/landing-page-seo-and-rotation.md
Original file line number Diff line number Diff line change
@@ -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.
104 changes: 104 additions & 0 deletions .changeset/navbar-handover-jerk.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added frontend/public/hero-divider.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion frontend/src/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
<BlogNavbar />

{/* Static Header Spacer */}
<div id="static-navbar" className="h-1"></div>
<div className="h-1"></div>

{/* Article Container */}
<article className="w-full max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
<BlogNavbar />

{/* Static Header Spacer */}
<div id="static-navbar" className="h-1"></div>
<div className="h-1"></div>

{/* Hero Section */}
<section className="pt-16 bg-background">
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 12 additions & 3 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<meta name="google-site-verification" content="your-google-verification-code">`
* 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({
Expand Down
Loading
Loading