Skip to content
Open
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
70 changes: 70 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,76 @@ moving is read where the page is:
entirely behind the bar. It cannot simply be measured, because the browser
scrolls to a deep link while the document is still parsing. `NavBar`
re-publishes the row's measured height once it has one.
- **The status bar is not ours to paint.** Safari 26 ignores `theme-color`
outright, and in a tab the page is not laid out under the bar either —
`env(safe-area-inset-top)` is 0 and `innerHeight` is 714 against a 874pt
screen — so no pixel of this site can reach it. What it does instead is read
the `background-color` of a fixed or sticky element at the top of the viewport
and fill the bar with that. `.nav-shell` is the candidate and it is
transparent, so there is nothing to read and the bar is glass over whatever
page content is beneath it: on a dark article that reads as a slab, which is
the seam above the frosted header.

Measured on an iPhone 16 Pro with `static/safari-bar-probe.html`, which is the
only reason any of this is written down — every account of the scan online
disagrees with the others, and two of them are wrong:
- an opaque `background-color` on the candidate paints the bar that colour
flat, and a semi-transparent one plus a `backdrop-filter` paints it as that
material over the content, which is the look this header wants;
- **an `opacity: 0` element is not read**, so there is no invisible element
that can hand Safari the tint — a 4px sliver doing exactly that was tried
and does nothing;
- with no candidate at all the bar is the page, blurred.

**`.nav-edge` is what answers it, and the header's ramp is what makes that
possible.** The material is a gradient now — the page's own colour, solid at
the top edge and gone at the bottom — so the colour the bar needs is a colour
the header already has. `.nav-edge` is 4px of it, `position: fixed` at
`top: 0`, over the scan's threshold and invisible by construction: the pixels
directly beneath it are the same colour, at the top of the page and scrolled.
It is never faded and never animated, because the value that counts is the one
there at first paint. The shell stays transparent — a colour on it would paint
the whole row and there would be no ramp to speak of.

The `theme-color` metas in `app.html` are still what Chrome's toolbar reads.

- **The header's material is a ramp, not a pane**, and both halves of it are
built to be even. Four masked `backdrop-filter` passes, each band starting
exactly at the midpoint of the one below it — 18% apart, 36% wide — so the
number of passes over any point climbs 1, 2, 3, 4 in equal measure; a
`backdrop-filter` takes in its earlier siblings, so they compound. Bands that
abut show their seams and bands that overlap unevenly read as a ramp with a
lump in it. They stop at 90%, because above that the scrim is near-solid and a
blur under an opaque colour is work nobody sees.

Over them, one scrim of the page colour: **a smoothstep sampled at eight even
intervals, which is symmetric** — every pair either side of the middle sums to
1 — and flat at both ends. The flat ends are the point. A straight alpha ramp
gives way at the very top edge, where the article shows through the pixels
that are supposed to match the status bar, and it ends in a visible line at
the bottom. There is no hairline: the header has no bottom edge to draw.

It all hangs off `.nav-surface`, which keeps the opacity ramp and the growth,
and every stop is a percentage — so opening the disclosure lengthens the ramp
rather than sliding it down, ends pinned and the curve spread over the taller
header. The one thing a ramp cannot do is hold a row of links at its own
transparent end, so `.nav-panel` is a flat tint **under** the scrim at
`0.8 × --nav-surface-menu`. Compositing the gradient over it is the ramp lerped
into that floor — 1 at the top, floor at the far end, same shape between — which
is what keeps the fade running the full height of the open header instead of
collapsing into its top quarter. Putting the tint over the scrim instead just
flattens it.

- **`--nav-safe-top` reserves the bar's band inside the row, for the cases where
the page really is laid out under it** — a home-screen web app, and anything
else where `env(safe-area-inset-top)` is not 0. It is added to the row's
`padding-block-start`, not to the shell, which would put the band outside
`.nav-surface`'s box and stop the blur short of the top of the screen. `--nav-h`
is that reserve plus the row, so `scroll-padding-top` and the hero's timeline
inset both clear the taller bar; `--nav-name-travel` takes the reserve back
out, because the band moves the header's bottom edge and the wordmark's resting
position by the same amount and the handover is the difference. In a Safari tab
it resolves to 0px and every one of these is the number it always was.
- **The surface is a scroll-driven animation, not a scroll listener.** A
`scroll(root block)` timeline carries `--nav-surface-scroll` from 0 to 1 over
`--nav-surface-range`. The listener is installed only where
Expand Down
23 changes: 22 additions & 1 deletion src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,19 @@
height once it has one, so a row that is ever set taller — or moved by the
dials on a preview — carries all of this with it. */
:root {
--nav-h: 3.5rem;
/* The page is laid out `viewport-fit=cover`, so on a phone it starts at the
physical top edge and the status bar's clock and battery sit over the
first 59px of it. The header reserves that band inside itself — the row
is padded down out of it while the surface and the hairline still span
it — so the material reaches the edge and nothing legible is under the
furniture. It is a token because it is part of the header's height, and
everything that measures from the bottom of the bar has to see it.

Every other browser, and iOS in landscape, resolves this to 0px and
every rule below is exactly what it was. */
--nav-safe-top: env(safe-area-inset-top, 0px);

--nav-h: calc(3.5rem + var(--nav-safe-top));

/* How tall an image in an article is allowed to be. A token because the
placeholder has to compute the same box the loaded photo will occupy,
Expand All @@ -77,6 +89,15 @@
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;

/* The site follows the system scheme — `app.html` reads the media query
before first paint and puts the class on this element — but until this
is declared the UA is still rendering everything it owns as a light
page: the canvas behind the document, form controls, and the furniture
a browser paints around the content, which on iOS 26 includes the
status bar's own base. Both keywords, because the answer is the
system's rather than ours. */
color-scheme: light dark;

/* Every kind of jump the browser performs lands its target at the top of
the scrollport, which is behind the header: a `#hash` link, the table of
contents, find-in-page, scroll-to-text-fragment and tabbing to something
Expand Down
209 changes: 187 additions & 22 deletions src/lib/nav/NavBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,22 @@
</noscript>
</svelte:head>

<!-- The 4px that hands Safari the colour, and the one element on this page that
is there for the browser's chrome rather than for the page.

Safari 26 fills its status bar with the `background-color` of a fixed or
sticky element at the top of the viewport. Measured on an iPhone: an opaque
one paints the bar that colour flat and edge to edge, and with no candidate
the bar is the page behind it, blurred — which on a dark article is the slab
this is all about. `.nav-shell` cannot be the candidate: a colour on it
paints the whole row, and the row is a ramp now.

So this is the top stop of that ramp, on its own, 4px of it — over the
threshold the scan wants, and invisible by construction, because the pixels
directly under it are the same colour. At the top of the page it is the page
colour on the page colour; scrolled, it is the scrim's own first stop. -->
<div aria-hidden="true" class="nav-edge"></div>

<!-- The header's priority order, expressed as layout rather than hoped for: type
never shrinks on a small screen — the link set does. Below `sm` the nav is
'projects blog ⌄' and /now + /health live one tap down; the wordmark still
Expand Down Expand Up @@ -213,16 +229,38 @@
the top of the page, doing its work behind a fully transparent tint, and
fading the element takes the filter with it — so a header sitting over
nothing composites nothing. -->
<div
aria-hidden="true"
class="nav-surface absolute inset-0 -z-10 bg-white/70 backdrop-blur-md dark:bg-neutral-950/70"
></div>
<!-- And one hairline, which slides down to the new bottom edge as the
disclosure opens instead of a second one fading in beneath it. -->
<div
aria-hidden="true"
class="nav-hairline absolute inset-x-0 bottom-0 h-px bg-neutral-200/70 dark:bg-neutral-800/70"
></div>
<!-- The material is a ramp rather than a pane: it is the page's own colour at
the top edge and nothing at all at the bottom, with the blur deepening
the same way. The header stops having a bottom — no hairline, no step —
and the top is a colour rather than a translucency, which is what lets
the status bar above it be the same colour and the two read as one
surface running off the top of the screen. -->
<div aria-hidden="true" class="nav-surface absolute inset-0 -z-10">
<!-- Four passes, each masked to a band that starts higher than the last.
A `backdrop-filter` takes everything painted beneath it, siblings
included, so the passes compound: one radius at the bottom of the
ramp and four of them at the top, from a step small enough that no
single layer is expensive. The bands overlap by their own width, and
that overlap is the whole reason it reads as a gradient rather than
as four bars of increasing blur. -->
<div class="nav-blur" data-step="1"></div>
<div class="nav-blur" data-step="2"></div>
<div class="nav-blur" data-step="3"></div>
<div class="nav-blur" data-step="4"></div>
<!-- Over the blur, not under it: this is the colour the type sits on, and
a scrim that had been through the passes above would be a blurred
gradient, which is a gradient with its ends pulled in. The stops are
eased — a straight alpha ramp ends visibly, in a soft edge across the
page about two thirds of the way down. -->
<!-- Under the scrim, not over it, and that ordering is the whole of how the
open disclosure stays legible without flattening the ramp. A gradient
composited over a flat tint is the ramp lerped into it — floor at the
far end, still 1 at the top, the same shape in between — so the links
get their backing and the fade still runs the full height of the
grown header instead of collapsing into its top quarter. -->
<div class="nav-panel"></div>
<div class="nav-scrim"></div>
</div>
<!-- Centred, so the name and the links hang from one middle axis rather than
standing on one baseline — small links sharing a baseline with type this
much larger read as sitting on the floor beside it.
Expand Down Expand Up @@ -405,9 +443,20 @@
Tailwind class carries, so a build with the panel folded away renders
exactly what the classes say. Overrides with `sm` variants are held below
that breakpoint: the dial is for the phone header. */
/* The status bar's band is reserved inside the row, not above it. The page is
laid out `viewport-fit=cover`, so the sticky shell's `top: 0` is the
display's own top edge and the clock sits over whatever is in the first
59px of the header — the wordmark, on every page. Padding the row down out
of that band leaves the surface, which is `inset-0` of the wrapper around
this row, spanning it: the material goes edge to edge and the type does not.

The alternative — padding the shell — would have put the band *outside* the
surface's box and painted the header's blur short of the top of the screen,
which is the thing being fixed. */
.nav-row {
align-items: var(--nav-align, center);
padding-block: var(--nav-row-pad-y, 0.75rem);
padding-block: calc(var(--nav-row-pad-y, 0.75rem) + var(--nav-safe-top, 0px))
var(--nav-row-pad-y, 0.75rem);
}

.nav-cluster {
Expand Down Expand Up @@ -514,11 +563,15 @@
It lands *on* the hero name, not merely in step with it. `--nav-name-travel`
is the gap between them and is derived, not chosen: the inset starts the
range with the hero name `--nav-h` down, the wordmark rests
`(--nav-h - 2rem) / 2` from the row's top, and the difference folds to
`(--nav-h + 2rem) / 2` — 44px. That same value is the range's end in px of
scroll, which is what makes the rate exactly 1:1. Anything else leaves the
two names a constant 12px apart, which is a double image. There is
deliberately no dial for it.
`--nav-safe-top + (--nav-h - --nav-safe-top - 2rem) / 2` from the top of the
viewport, and the difference folds to `(--nav-h - --nav-safe-top + 2rem) / 2`
— 44px on a desktop, and still 44px on a phone, because the status bar's
band lengthens the header and the wordmark's rest position by exactly the
same amount. That value is also the range's end in px of scroll, which is
what makes the rate exactly 1:1. Anything else leaves the two names a
constant gap apart, which is a double image — and taking the reserve out is
not optional here: left in, the two would be 30px apart for the whole
handover on every iPhone. There is deliberately no dial for it.

`.nav-name` is the hole it rolls through, and has to be a second element:
the clip must hold still while the type inside it moves.
Expand All @@ -542,7 +595,7 @@
}

.nav-name-roll {
--nav-name-travel: calc((var(--nav-h, 3.5rem) + 2rem) / 2);
--nav-name-travel: calc((var(--nav-h, 3.5rem) - var(--nav-safe-top, 0px) + 2rem) / 2);

opacity: 1;
translate: 0 0;
Expand Down Expand Up @@ -610,16 +663,128 @@
}

/* Sized by the row and stretched by however far the disclosure reaches, so one
blurred pane covers the header at every point in the animation. Their
presence is not transitioned here — the two properties above carry it, and a
transition on top of a scroll-driven value would only make it lag. */
.nav-surface,
.nav-hairline {
ramp covers the header at every point in the animation — and stretching it
is what keeps the fade the height of whatever the header currently is,
rather than a gradient that ends partway down an open menu. Its presence is
not transitioned here — the two properties above carry it, and a transition
on top of a scroll-driven value would only make it lag. */
.nav-surface {
opacity: max(var(--nav-surface-scroll), var(--nav-surface-menu));
bottom: calc(-1 * var(--nav-open-extra, 0px));
transition: bottom var(--nav-duration, 320ms) var(--nav-ease);
}

/* The page's own background, as the three numbers a gradient stop can be
written from. `.nav-edge` is not inside the surface — it is fixed to the
viewport — so both are named here rather than the value living on one of
them. */
.nav-surface,
.nav-edge {
--nav-surface-rgb: 255 255 255;
}

:global(html.dark) .nav-surface,
:global(html.dark) .nav-edge {
--nav-surface-rgb: 10 10 10;
}

.nav-surface > * {
position: absolute;
inset: 0;
}

/* One radius per pass, compounding upward. 3px is chosen against the four of
them: the top of the ramp lands near the 8px this header has always used,
and no single layer is doing the expensive thing on its own. */
.nav-blur {
-webkit-backdrop-filter: blur(var(--nav-blur-step, 3px));
backdrop-filter: blur(var(--nav-blur-step, 3px));
}

/* Evenly stepped, and each band starts exactly at the midpoint of the one
below it: 18% apart, 36% wide, so the count of passes over any given point
climbs 1, 2, 3, 4 in equal measure from the bottom edge to the top. Even
spacing is the whole difference between a ramp and four bars of increasing
blur — bands that abut show their seams, and bands that overlap unevenly
read as a ramp with a lump in it.

Written to the top, because that is the end the ramp is anchored to: an open
disclosure lengthens the box downward and the deep end stays where it is.
They stop at 90% rather than 100% because the scrim is near-solid above
that, and a blur under an opaque colour is work nobody sees. */
.nav-blur[data-step='1'] {
-webkit-mask-image: linear-gradient(to top, transparent 0%, #000 36%);
mask-image: linear-gradient(to top, transparent 0%, #000 36%);
}

.nav-blur[data-step='2'] {
-webkit-mask-image: linear-gradient(to top, transparent 18%, #000 54%);
mask-image: linear-gradient(to top, transparent 18%, #000 54%);
}

.nav-blur[data-step='3'] {
-webkit-mask-image: linear-gradient(to top, transparent 36%, #000 72%);
mask-image: linear-gradient(to top, transparent 36%, #000 72%);
}

.nav-blur[data-step='4'] {
-webkit-mask-image: linear-gradient(to top, transparent 54%, #000 90%);
mask-image: linear-gradient(to top, transparent 54%, #000 90%);
}

/* A smoothstep, sampled at eight even intervals, and the reason to spend eight
stops on it is that it is symmetric: every pair either side of the middle
sums to 1, and the curve leaves both ends flat. That flatness is what a
straight alpha ramp cannot give — it starts giving way at the very top edge,
where the article then shows through the pixels that are supposed to match
the status bar, and on a light page that reads as dirt under the wordmark.
It ends the same way, so there is no line where the header stops.

Every stop is a percentage of a box that is the row plus however far the
disclosure currently reaches, so opening it lengthens the ramp rather than
sliding it down: the ends stay pinned and the curve is spread over the
taller header. That is why the stops are percentages and the growth is on
the parent. */
.nav-scrim {
background-image: linear-gradient(
to bottom,
rgb(var(--nav-surface-rgb) / 1) 0%,
rgb(var(--nav-surface-rgb) / 0.959) 12.5%,
rgb(var(--nav-surface-rgb) / 0.844) 25%,
rgb(var(--nav-surface-rgb) / 0.684) 37.5%,
rgb(var(--nav-surface-rgb) / 0.5) 50%,
rgb(var(--nav-surface-rgb) / 0.316) 62.5%,
rgb(var(--nav-surface-rgb) / 0.156) 75%,
rgb(var(--nav-surface-rgb) / 0.041) 87.5%,
rgb(var(--nav-surface-rgb) / 0) 100%
);
}

/* The floor the ramp is lerped into while the disclosure is down. Its own
opacity carries it, on the menu's half of the pair the surface fades on, so
it eases in over the same 200ms as everything else the menu moves — and the
closed header composites against nothing at all and is the ramp it has
always been. */
.nav-panel {
background-color: rgb(var(--nav-surface-rgb));
opacity: calc(0.8 * var(--nav-surface-menu));
}

/* Fixed rather than sticky: it answers to the viewport, which is what the scan
measures against, and it must not move with a header that is only sticky
until the page is long enough. Never faded and never animated — the value
Safari reads is the one that is there at first paint. */
.nav-edge {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 4px;
background-color: rgb(var(--nav-surface-rgb));
pointer-events: none;
z-index: 31;
}

.nav-more-row {
padding-top: max(0px, var(--nav-more-lead, -10px));
padding-right: var(--nav-more-pad-r, 41px);
Expand Down
Loading
Loading