From 4b4216b04f201ebbb4a75ddea398bd3df5f5d962 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Sun, 9 Aug 2026 12:53:09 +0200 Subject: [PATCH] Keep the list back until the filter has been applied (#161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Keep the list back until the filter has been applied Arriving on `?q=` or `?library=` meant watching all ~160 cards paint and then jump to the handful that match. Nothing React does can help: the site is `output: "export"`, so the list ships rendered whole in the HTML and paints before the JS that will narrow it has even loaded. Next has an answer for this — a `` boundary around a `useSearchParams` consumer, which drops that subtree from the static HTML — but it is a build-time decision, so it would cost the prerendered list for every visitor to spare the flash for the few who arrive filtered. Instead the boot script, which already reads the URL for `?nav=`, marks a filtered arrival on ; `globals.css` holds the list back until `Nav` takes the mark over, alongside the collapse one. Only filtered arrivals pay anything, and the list still ships prerendered for everyone else. `visibility`, not `display`, so the cards keep the layout the list windowing measures. That script is now a real function, serialized with `String(bootNav)` rather than written as a template literal: typed, formatted and linted like the rest of the file. It has to stay hermetic to survive stringification, so the storage key comes in as an argument. A filter now also beats a stored collapse in the script itself, matching what `Nav` already did on the client — the rail no longer paints shut and swings open on a shared filter link. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9J9K5TobdJo49EU4Z1Fp4 * Fill the wait with skeletons, and stop the pill's label arriving late Two leftovers around what the rail looks like before React owns it. A filtered arrival now waits in front of a handful of skeletons rather than an empty scroller -- blank reads as "nothing here", a shimmer reads as "coming". They are laid over the list rather than above it, because the list has to keep its boxes: `visibility`, not `display`, is what keeps them, and hiding it outright would collapse every card to nothing on the very commit the list windowing measures on -- every card would read as on-screen and all ~160 thumbnails would mount at once. And the toggle's "hide"/"show" was held back until mounted, on the grounds that the collapsed state came out of `localStorage` and the word would otherwise be a coin flip. It no longer does: `shown` reaches the pre-paint mark through `useSyncExternalStore`, so the word is right on the first client render, and the pill stops reflowing around a label that turns up a beat later. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9J9K5TobdJo49EU4Z1Fp4 * Skeleton the filter row too, and give the cards their tag strips The placeholder cards were bare rectangles where the real ones carry a strip of tag pills over the bottom corner, so the shape shifted under the visitor at the very moment the list landed. They now carry the strip, darker than the card the way the real pills are -- at the card's own tone they read as holes punched in it. Widths are written out rather than drawn at random, since the markup has to come out the same on both sides of hydration. The filter row had the same problem one line up, and worse: whichever of its two forms the HTML was built with is the wrong one on a filtered arrival -- the dropdown standing where the search field belongs on `?q=`, or the dropdown with nothing selected on `?library=`. It gets a placeholder of its own, with the two forms wrapped in a `display: contents` element so standing in front of them changes nothing about how they lay out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9J9K5TobdJo49EU4Z1Fp4 --------- Co-authored-by: Claude Opus 5 (1M context) --- apps/website/app/globals.css | 37 +++++ apps/website/app/layout.tsx | 61 +++++++-- apps/website/components/Nav.tsx | 230 ++++++++++++++++++++------------ 3 files changed, 231 insertions(+), 97 deletions(-) diff --git a/apps/website/app/globals.css b/apps/website/app/globals.css index 20282f4e..2061f120 100644 --- a/apps/website/app/globals.css +++ b/apps/website/app/globals.css @@ -350,3 +350,40 @@ margin-inline-start: calc(var(--sidebar-width) * -1); } } + +/* Same story for `?q=` and `?library=`, one step earlier: a static export + ships the list rendered whole, so all ~160 cards paint while the JS that + will narrow them down is still loading, and the filter lands as a visible + jump. `bootNav` marks a filtered arrival, and these hold the list back + behind a handful of skeletons until React has applied it -- only for those + arrivals; everyone else gets the prerendered list painted as it always was + and never renders the skeletons at all. + + `visibility`, not `display`: the cards have to keep their boxes, both + because the list windowing measures them on that very commit -- collapsed + to nothing, every card reads as on-screen and all ~160 thumbnails mount at + once -- and because it is what lets the skeletons sit over the list rather + than push it down. */ +#example-skeletons, +#nav-filters-skeleton { + display: none; +} + +html[data-nav-filtering] #example-skeletons { + display: flex; +} + +html[data-nav-filtering] #nav-filters-skeleton { + display: block; +} + +html[data-nav-filtering] #example-list { + visibility: hidden; +} + +/* The filter row is the same story in miniature: whichever of its two forms + the HTML was built with is the wrong one here -- the dropdown where the + search field belongs, or the dropdown with nothing selected. */ +html[data-nav-filtering] #nav-filters { + display: none; +} diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index 23cec686..d5b4f0aa 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -12,6 +12,50 @@ import { builder } from "material-theme-builder"; const inter = Inter({ subsets: ["latin"] }); const examples = getExamples(); +/** + * Two things only the client knows, both needed before the first paint, so the + * blocking script below runs this and leaves the verdict on : whether the + * rail starts collapsed, and whether this is a filtered arrival — `?q=` + * /`?library=`, whose list must not paint whole while the JS that will narrow + * it is still loading. `globals.css` acts on both marks and `Nav` takes them + * over, then drops them. A filter beats a stored collapse: a shared link has to + * be able to show what it filtered down to. + * + * A function rather than a template string, so it is typed, formatted and + * linted like everything else — `String(bootNav)` is what ends up in the page. + * The catch that comes with that: it has to stay hermetic. No imports, no + * module-level constants, nothing but its arguments, or the bundler leaves a + * dangling reference in the string. Hence the storage key coming in as one — + * `Nav` owns the other half of that contract. + */ +function bootNav(storageKey: string) { + const params = new URLSearchParams(window.location.search); + const filtering = !!(params.get("q") || params.get("library")); + + const isCollapsed = () => { + const parts = window.location.pathname.split("/").filter(Boolean); + const examplesIndex = parts.indexOf("examples"); + const onAnExample = examplesIndex !== -1 && !!parts[examplesIndex + 1]; + + /* Two cases where the rail stays out whatever this visitor last left it + at: the index, where there is nothing to look at beside it, and a + shared filter link, which has to show what it filtered down to. */ + if (!onAnExample || filtering) return false; + + /* Then `?nav=`, a link saying how it wants to arrive… */ + const nav = params.get("nav"); + if (nav === "closed") return true; + if (nav === "open") return false; + + /* …and failing that, wherever this visitor left it. */ + return localStorage.getItem(storageKey) === "1"; + }; + + const root = document.documentElement; + root.toggleAttribute("data-nav-collapsed", isCollapsed()); + root.toggleAttribute("data-nav-filtering", filtering); +} + /** * The one hex the whole palette hangs off -- poimandres' signature mint. * Material Color Utilities derives every `--md-sys-color-*` role from it, and @@ -81,21 +125,10 @@ export default function RootLayout({ precedence="high" dangerouslySetInnerHTML={{ __html: mcuCss }} /> + {/* Blocking on purpose: `bootNav` settles what the rail looks like + before anything paints. */}