From 678f074bd5af0c625ea212c86feee2087cc14b2d Mon Sep 17 00:00:00 2001 From: Harsh Singh Date: Sat, 3 Oct 2026 20:02:06 +0530 Subject: [PATCH] Undim the banner link, and keep the preview probe out of first paint --- src/__tests__/appAccessibility.test.ts | 11 +++++++ src/components/Navbar.tsx | 2 +- src/components/StylePreview.tsx | 42 ++++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/__tests__/appAccessibility.test.ts b/src/__tests__/appAccessibility.test.ts index 6eec27a..b954c12 100644 --- a/src/__tests__/appAccessibility.test.ts +++ b/src/__tests__/appAccessibility.test.ts @@ -388,3 +388,14 @@ describe("the sticky nav reads on both grounds", () => { expect(block).toContain("--primary-ink: oklch(0.55 0.19 256.4)"); }); }); + +describe("the announcement banner reads at full strength", () => { + it("does not dim its own link text", () => { + // Measured on production: text-white/85 on the banner resolved to #d9e9fa against + // rgb(1,109,221), which is 4.01:1, and Lighthouse failed the page's colour contrast + // for it. Full white on the same background measures 4.96:1. + const navbar = readFileSync("src/components/Navbar.tsx", "utf8"); + const banner = navbar.slice(navbar.indexOf("bg-primary"), navbar.indexOf("bg-primary") + 1200); + expect(banner, "the banner dims its link text again").not.toMatch(/text-white\/\d+/); + }); +}); diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 8896bd3..92716ed 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -70,7 +70,7 @@ export default function Navbar({ position = "sticky" }: { position?: "fixed" | " className="group mx-auto flex max-w-[1360px] flex-col items-center justify-center gap-x-10 gap-y-1 px-4 py-2.5 text-center text-sm sm:flex-row" > Three finished sites, served as the exact bundles they export to. - + See the examples diff --git a/src/components/StylePreview.tsx b/src/components/StylePreview.tsx index 19b6cdd..3bfbd63 100644 --- a/src/components/StylePreview.tsx +++ b/src/components/StylePreview.tsx @@ -33,8 +33,25 @@ type FrameOptions = Parameters[4]; const REST_CACHE = new Map(); const REST_CANDIDATES = [0, 0.15, 0.3, 0.45, 0.6, 0.75, 0.9]; +function restKey(opts: FrameOptions, maxProgress: number): string { + return `${opts.style}|${opts.color1}|${opts.color2}|${opts.color3}|${maxProgress}`; +} + +export function knownRestingProgress(opts: FrameOptions, maxProgress: number): number | undefined { + return REST_CACHE.get(restKey(opts, maxProgress)); +} + +/** + * Seven draws and seven readbacks, for one number. + * + * Cheap on its own and ruinous in a group: the landing page mounts four of these, and + * running the probe inside the first paint put 28 synchronous getImageData calls in front + * of the page settling. Measured with Lighthouse, that page blocked for 480ms while every + * other page in the site blocked for none. It runs off the critical path now, so call + * knownRestingProgress first and paint something while this works. + */ function restingProgress(opts: FrameOptions, maxProgress: number): number { - const key = `${opts.style}|${opts.color1}|${opts.color2}|${opts.color3}|${maxProgress}`; + const key = restKey(opts, maxProgress); const cached = REST_CACHE.get(key); if (cached !== undefined) return cached; let best = 0; @@ -62,6 +79,13 @@ function restingProgress(opts: FrameOptions, maxProgress: number): number { return best; } +/** After the page has settled, or very soon, in a browser without requestIdleCallback. */ +function whenIdle(run: () => void): void { + const idle = (window as unknown as { requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number }).requestIdleCallback; + if (typeof idle === "function") idle(run, { timeout: 1200 }); + else setTimeout(run, 200); +} + /** * Renders a live, looping animation of a 2D style directly to a canvas via * requestAnimationFrame — no JPEG encoding, no IndexedDB. Cheap enough to mount @@ -122,8 +146,20 @@ export default function StylePreview({ ctx.setTransform(dpr, 0, 0, dpr, 0, 0); sizeRef.current = { w, h }; // Repaint immediately so a resize never leaves the canvas blank; a running - // loop overwrites this on its next tick. - drawFrame2D(ctx, w, h, restingProgress(optsRef.current, maxRef.current), optsRef.current); + // loop overwrites this on its next tick. The resting frame is whichever of the + // style's frames is brightest, and finding it costs seven readbacks, so the first + // paint uses the start of the range and the better frame arrives when the browser + // is idle. + const known = knownRestingProgress(optsRef.current, maxRef.current); + drawFrame2D(ctx, w, h, known ?? 0, optsRef.current); + if (known === undefined) { + whenIdle(() => { + const context = canvas.getContext("2d"); + if (!context) return; + const { w: width, h: height } = sizeRef.current; + drawFrame2D(context, width, height, restingProgress(optsRef.current, maxRef.current), optsRef.current); + }); + } }; resize();