russ/ap-bio AP Bio
Merge PR #12: web: offer to install the site as an app (claude/pwa-install-prompt-4c75d7)
merged by Russ T Fugalopened by Russ T Fugal9 files+1,977 −1831a77b312 merged here in total
description
Ports the install-prompt pattern from ~/code/sks/eclipse-calculator into this
app's idiom — no new dependencies, no shadcn Sheet or sonner, everything drawn
in the site's own nl-* primitives.
What lands
src/install/platform.ts— which install story the browser has. An iPad is told apart from a Mac bymaxTouchPoints, since iPadOS 13+ sends a Macintosh user agent and a string match alone hands every iPad a Chromium flow it can never receive.src/install/state.ts— the offer's memory, underapbio.install.v1. Not on the first load; a thirty-day snooze per dismissal; a hard stop after three.src/install/deferredPrompt.ts— capturesbeforeinstallpromptfrommain.tsxbeforehydrateRoot. The version this was ported from registers that listener in a component effect, which is a coin flip: Chromium fires the event once, early, and routinely before React is up.src/install/useInstallOffer.ts— the policy.describeOfferis pure and tested.src/install/InstallOffer.tsx— the panel, mounted in the layout beside the shorts overlay. Native platforms get an Install button; iOS gets a disclosure that unfolds the Share → Add to Home Screen steps in place, so the steps stay on screen while the student reaches for the browser's own Share button.
Notes
- Nothing is read during render, so the prerendered markup and the first client
render agree — same discipline as
useSavedClips. - The panel does not render while the shorts feed is open: it would be covered by the overlay but still focusable, which is a keyboard trap.
- Firefox and desktop Safari get silence. Both can install this site, by menu paths that differ by version and that the page cannot verify it is describing correctly.
Verified
bun run check clean, 117 tests pass, full build + prerender + sw green.
Both variants and the Escape dismiss were exercised in the browser at phone and
desktop widths.
9 files changed
This view needs a browser with declarative shadow DOM: Chrome 111, Safari 16.4, or Firefox 123. Read the source instead.
apps/web/src/install/InstallOffer.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449/** * The offer itself — a strip along the bottom of the page, in the site's own * housing rather than a browser-shaped card. * * Two shapes behind one panel, chosen by ./useInstallOffer.ts: * * - **native**: a button. The browser owns the dialog; the site owns only * *when* it is asked for. * - **manual** (iOS): a button that unfolds three steps in place. Deliberately * folded to start — the offer's job on first sight is to be refusable in one * tap, and a panel that opens at the height of a tutorial is a panel that * covers the page it is interrupting. Unfolding in place rather than in a * modal sheet is what lets the student keep the steps on screen while they * reach for the browser's own Share button, which is the entire point: * those two things have to be visible at the same time. * * Not a modal, so no focus trap and no inert background: this interrupts, it * does not block. Escape still closes it, because a thing that appears * uninvited over the page should answer to the key that dismisses everything * else on the web. Refusing to trap focus is not the same as being allowed to * throw it away, though — see `releaseFocus` below for what happens to the * caret when the panel is the thing that leaves. */import { useCallback, useEffect, useId, useRef, useState } from "react";import { CellMark } from "../components/CellMark";import { useShortsOverlay } from "../shorts/ShortsOverlay";import type { InstallPlatform } from "./platform";import { useInstallOffer } from "./useInstallOffer";
/** * The two glyphs the steps point at, drawn to match the ones iOS actually * shows — a student scanning the Share sheet is looking for a shape, not a * word, and a wrong shape sends them past the row they need. * * `inline-block` and the negative baseline shift are not decoration: Tailwind's * preflight sets `svg { display: block }`, which inside a sentence puts the * glyph on its own line. Both of these sit *in* a sentence. */function ShareGlyph() { return ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="text-lumen mx-0.5 inline-block size-4 align-[-0.22em]" > <path d="M12 15V3" /> <path d="M8 6.5 12 2.5l4 4" /> <path d="M6 11H4.5v10.5h15V11H18" /> </svg> );}
function AddGlyph() { return ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="text-lumen mx-0.5 inline-block size-4 align-[-0.22em]" > <rect x="3.5" y="3.5" width="17" height="17" rx="4.5" /> <path d="M12 8.5v7M8.5 12h7" /> </svg> );}
function CloseGlyph() { return ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true" className="size-3.5" > <path d="M5 5l14 14M19 5 5 19" /> </svg> );}
/** * "Home screen" or "computer" — the difference between a phone and a laptop is * the one thing the student can check against the sentence, so getting it * wrong is the fastest way to read as boilerplate. */function headline(platform: InstallPlatform): string { return platform === "desktop" ? "Install AP Biology on this computer" : "Add AP Biology to your home screen";}
/** * The three steps, unchanged on iOS for about a decade. * * "the browser toolbar" rather than "Safari's toolbar": since iOS 16.4 the * Share sheet in Chrome and Firefox carries Add to Home Screen too, and the * toolbar is where the button is in all of them — at the bottom on a phone, * at the top on an iPad. Naming a browser we have not actually detected would * be the one instruction here capable of being wrong. */function IosSteps({ id }: { id: string }) { return ( <ol id={id} // Tailwind's preflight sets `ol,ul,menu { list-style: none }`, and WebKit // strips list semantics from any list styled that way — so on iOS // VoiceOver, the one platform these steps exist for, an unadorned `<ol>` // announces as three loose paragraphs with no position in a sequence. // `role="list"` puts the semantics back, which is also what makes the // `aria-hidden` on the drawn numbers below safe to rely on. role="list" className="border-etch/60 mt-3.5 space-y-2.5 border-t pt-3.5" > <li className="text-muted-foreground flex gap-2.5 text-sm leading-6"> <StepNumber n={1} /> <span> Tap <ShareGlyph /> <span className="text-foreground">Share</span> in the browser toolbar </span> </li> <li className="text-muted-foreground flex gap-2.5 text-sm leading-6"> <StepNumber n={2} /> <span> Scroll down to <AddGlyph />{" "} <span className="text-foreground">Add to Home Screen</span> </span> </li> <li className="text-muted-foreground flex gap-2.5 text-sm leading-6"> <StepNumber n={3} /> <span> Tap <span className="text-foreground">Add</span> </span> </li> </ol> );}
/** * `aria-hidden`, because the list is already an `<ol>`: a screen reader * announces "1 of 3" from the markup, and the drawn circle would make that * "one, one of three". That premise is only true because of the explicit * `role="list"` above — without it, on iOS, the positional announcement this * defers to is the announcement that goes missing. */function StepNumber({ n }: { n: number }) { return ( <span className="border-etch text-lumen mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border font-mono text-[0.62rem]" aria-hidden="true" > {n} </span> );}
/** * What installing actually gets you — and the two shapes of the offer do not * get you the same thing, so they are not told the same sentence. * * Both halves of the sentence this replaces were false. "Opens full screen" * is not what public/manifest.webmanifest asks for: `"display": "standalone"` * takes away the browser's own toolbars and leaves the status bar and the home * indicator exactly where they were — a window of its own, not full screen. * And "the units you have read stay available offline" described a worker that * does not exist. public/sw.js precaches a fixed list of page paths injected at * build time from `precacheRoutes()` (scripts/build-sw.ts) — every unit and * both its decks, read or not — and everything else reaches the cache * stale-while-revalidate as it is fetched. Nothing in it has any idea which * units anyone has read. * * The native line below is the weaker claim that survives all of that. An * installed Chromium app is the same origin in the same storage as the tab it * was installed from, so the registered worker, the cache, and the Leitner * history are all already there — and a page that has been fetched has been * cached, because the fetch handler caches every same-origin GET it can. * * iOS gets a different sentence because iOS is a different container: a * home-screen web app there is walled off from the browser's storage, so the * app's first launch finds no registered worker, an empty cache, and none of * the boxes the student's cards were sorted into. Telling them their reading * comes with them is precisely the promise that first launch breaks, and this * panel is the last moment at which saying so is still useful. */function subhead(kind: "native" | "manual"): string { return kind === "native" ? "Opens in a window of its own, and pages you have already visited stay readable offline." : "Opens without the browser's toolbars. iOS gives it storage of its own, so it starts fresh — what you have read and answered stays here in the browser.";}
/** * The one thing ever put into the live region, and why there is one. * * The panel is inserted a couple of seconds after load, at the very end of the * document. It is a properly named `region` landmark, so it can be found — but * only by someone who thinks to go looking, and nothing tells them to. This is * the telling: three new controls exist, they are at the end, and Escape now * means something it did not mean a moment ago. * * It is a separate `role="status"` line inside the dock rather than `aria-live` * on the `<section>` on purpose. A live region wrapping the panel would re-read * the whole offer every time the disclosure folded or unfolded, which is the * one interaction this panel has. */const ARRIVAL_ANNOUNCEMENT = "Install offer added at the end of the page. Press Escape to dismiss it.";
/** * A live region only announces a *change*, so it has to be in the accessibility * tree and empty before its text arrives. Rendering the sentence in the same * commit that inserts the element is the classic way to have it read out * nowhere; a tick's gap is the classic way to fix it. */const ANNOUNCE_DELAY_MS = 120;
export function InstallOffer() { const { offer, install, dismiss } = useInstallOffer(); const { open: shortsOpen } = useShortsOverlay(); const [stepsShown, setStepsShown] = useState(false); const [announcement, setAnnouncement] = useState(""); const announcedRef = useRef(false); const dockRef = useRef<HTMLDivElement | null>(null); const titleId = useId(); const stepsId = useId();
const offered = offer.kind !== "hidden"; // The feed is a full-screen lid at a higher stacking level // (`.nl-shorts-overlay`, z-index 60), and swiping left opens it from // anywhere on the site — including from a finger that started on this panel. // The panel is hidden rather than unmounted for that trip, so a student who // has unfolded the steps and then swiped the feed in and back out returns to // the step they were on instead of to a refolded, re-animated panel. // `visibility: hidden` on the dock (styles.css) is what keeps that from // being a trap: it takes the panel out of the tab order and off the // accessibility tree as surely as unmounting did, which was the only reason // unmounting was there. const coveredByFeed = offered && shortsOpen; const isVisible = offered && !shortsOpen;
/** * Hand focus back to the page before the panel stops existing. * * Dismissing is an activation of a button inside a subtree that is about to * be removed, and when the browser removes the focused element it drops * focus to `<body>` — which is not "nowhere", it is "the top", so the next * Tab starts the document over at the site header. The panel is the last * thing in the DOM, so a keyboard user has by definition already passed * everything else to reach it; starting them again is the whole cost. * * `<main>` is the target because it is the landmark the reading position is * inside, and the only stable one that outlives this element: focus lands in * the page content, Shift+Tab goes back toward whatever the student was on, * and a screen reader says "main" rather than re-reading from the top. It is * given `tabindex="-1"` on the way in, which makes it focusable by script * without adding it to anybody's tab order. * * `preventScroll`, because focusing an element scrolls it into view and * `<main>` begins just under the header — without it, dismissing would yank * the page back to the top, which is the thing this is trying to avoid. The * option lands at Safari 15, above BROWSER_FLOOR's safari12; below that it is * an ignored property on an options object, so the oldest supported browsers * get the scroll and every browser gets the focus. That is the right way * round. * * Still not a focus trap. The module comment argues against one and is * right — focus is free to leave this panel at any moment. This is only * about where focus goes when the panel is the thing doing the leaving. */ const releaseFocus = useCallback(() => { const dock = dockRef.current; const active = document.activeElement; if (dock === null || active === null || !dock.contains(active)) return; const main = document.querySelector<HTMLElement>("main"); if (main === null) return; main.tabIndex = -1; main.focus({ preventScroll: true }); }, []);
const dismissAndRelease = useCallback(() => { releaseFocus(); dismiss(); }, [releaseFocus, dismiss]);
// Focus moves on the click rather than when the panel finally comes down, // because what comes down is decided a promise later by the browser's own // dialog (see `install` in ./useInstallOffer.ts) and there is no commit here // to hang it off. Moving it early is invisible: the browser's install dialog // takes over the screen in the same gesture. const installAndRelease = useCallback(() => { releaseFocus(); install(); }, [releaseFocus, install]);
const toggleSteps = useCallback(() => { setStepsShown((shown) => !shown); }, []);
useEffect(() => { if (!isVisible) return; const onKeyDown = (event: KeyboardEvent) => { // "Esc" is the pre-`key`-standard spelling still sent by the oldest // browsers inside BROWSER_FLOOR (packages/config/vite.ts). if (event.key === "Escape" || event.key === "Esc") dismissAndRelease(); }; window.addEventListener("keydown", onKeyDown); return () => { window.removeEventListener("keydown", onKeyDown); }; }, [isVisible, dismissAndRelease]);
useEffect(() => { if (!isVisible || announcedRef.current) return; const timer = setTimeout(() => { announcedRef.current = true; setAnnouncement(ARRIVAL_ANNOUNCEMENT); }, ANNOUNCE_DELAY_MS); return () => { clearTimeout(timer); }; }, [isVisible]);
// Reserve the strip the dock covers, so the browser has somewhere to stop // when it scrolls a focused element into view — see `.nl-install-open` in // styles.css for what that fixes and for the floor it is missing on. useEffect(() => { if (!isVisible) return; const root = document.documentElement; root.classList.add("nl-install-open"); return () => { root.classList.remove("nl-install-open"); }; }, [isVisible]);
// Measured, not guessed: the dock is a different height on the two shapes of // the offer and again once the steps unfold, and a reserved strip that is // wrong in either direction is either a gap or a hole in the page. Remeasured // on resize because that includes a phone being turned sideways, which is the // case where the number changes most. useEffect(() => { if (!isVisible) return; const root = document.documentElement; const measure = () => { const dock = dockRef.current; if (dock === null) return; const { height } = dock.getBoundingClientRect(); root.style.setProperty("--nl-install-dock-h", `${Math.ceil(height)}px`); }; measure(); window.addEventListener("resize", measure); return () => { window.removeEventListener("resize", measure); root.style.removeProperty("--nl-install-dock-h"); }; }, [isVisible, stepsShown, offer.kind]);
// Rendering nothing until an effect has decided otherwise is what keeps the // prerendered markup and the first client render identical — see the comment // at the top of ./useInstallOffer.ts. Dismissal lands here too: once the // offer is retired the panel really is gone, not merely hidden. if (!offered) return null;
return ( <div ref={dockRef} className="nl-install-dock" data-open={coveredByFeed ? "false" : "true"} aria-hidden={coveredByFeed} inert={coveredByFeed} > <p className="sr-only" role="status"> {announcement} </p> <section className="nl-panel nl-install-panel" aria-labelledby={titleId} // Non-modal by design (see the module comment) — `role="dialog"` would // promise a focus trap and an inert page, neither of which is true here. role="region" > <button type="button" className="nl-install-close" onClick={dismissAndRelease} aria-label="Not now" > <CloseGlyph /> </button>
{/* The scrolling region, so a panel that outgrows a landscape phone can be read to the end instead of growing off the top of it. The close button is deliberately its sibling rather than its child, so it stays pinned to the housing while this scrolls. */} <div className="nl-install-scroll"> <div className="flex items-start gap-3.5"> <CellMark size={30} /> {/* `pr-7` keeps the title clear of the close button, which is absolutely positioned over this row's top-right corner. */} <div className="min-w-0 flex-1 pr-7"> <p className="nl-label">Install</p> <p id={titleId} className="mt-1 text-[0.95rem] leading-snug"> {headline(offer.platform)} </p> <p className="text-muted-foreground mt-1 text-sm leading-snug"> {subhead(offer.kind)} </p> </div> </div>
{offer.kind === "native" ? ( <button type="button" className="nl-action nl-action-primary mt-3.5 w-full justify-center" onClick={installAndRelease} > Install </button> ) : ( // A disclosure, not a swap: the button stays put and keeps its // `aria-expanded` truthful. Replacing it with the steps would leave // a screen reader that had just activated it with nothing focused // and no statement of what changed. <> <button type="button" className="nl-action mt-3.5 w-full justify-center" onClick={toggleSteps} aria-expanded={stepsShown} aria-controls={stepsId} > {stepsShown ? "Hide steps" : "Show me how"} </button> {stepsShown && <IosSteps id={stepsId} />} </> )} </div> </section> </div> );}apps/web/src/install/deferredPrompt.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144/** * Catching `beforeinstallprompt`, which fires whether or not anyone is * listening yet. * * Chromium dispatches it once, early, as soon as it decides the site is * installable — routinely before React has hydrated. A listener registered in * a component effect is therefore a coin flip: on a warm cache the event has * already come and gone and the page's install button is lost for the rest of * the visit. The browser's own mini-infobar is the one thing *not* lost by * missing the event — suppressing it is what `preventDefault()` does, so a * page that never hears the event leaves it free to appear. Losing our button * is reason enough: that is why this is a module with a `watch()` called from * src/main.tsx *before* `hydrateRoot`, and not a hook. * * The event is also single-use: `prompt()` may be called on it once, and the * handle is spent afterwards whatever the student chose. So this module owns * exactly one of them, hands out no references, and clears itself the moment * it is used — a second caller gets `"unavailable"` rather than a rejected * promise from a stale handle. * * None of this is typed by the DOM lib (`beforeinstallprompt` is not in any * standard), so the event shape is declared here. */import { isOfferSilenced, readInstallState } from "./state";
interface BeforeInstallPromptEvent extends Event { prompt: () => Promise<void>; readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;}
/** What `promptInstall` settled on. `unavailable` means it never ran. */export type InstallOutcome = "accepted" | "dismissed" | "unavailable";
let deferred: BeforeInstallPromptEvent | null = null;let installed = false;let watching = false;
const listeners = new Set<() => void>();
function notify(): void { for (const listener of listeners) listener();}
/** * Start listening. Idempotent, and safe to call off-browser. * * Never unsubscribes: these two listeners live as long as the document, the * same as the service worker registration next to the call site. */export function watchInstallability(): void { if (watching) return; if (typeof window === "undefined") return; watching = true;
window.addEventListener("beforeinstallprompt", (event: Event) => { // Two decisions here, and only one of them is conditional. // // The handle is kept every time. It is the only way to open the browser's // install dialog from a button of ours, it is dispatched once, and an // event that was not held on to cannot be got back. // // `preventDefault()` is a different matter, because it is what suppresses // Chromium's own mini-infobar. Calling it is only defensible while the // site still has an ask of its own to make: without it the browser shows // its infobar at a moment of its choosing and the site's offer becomes a // second, redundant ask. But once ./state.ts has gone quiet — three // dismissals spent, or an asking inside the last thirty days — a page that // goes on eating this event is taking away an affordance and putting // nothing in its place, leaving the student with *less* install UI than // one that had never listened at all. So the suppression lasts exactly as // long as the offer does. // // Reading storage from here is not a render read, and does not put // hydration at risk. This runs from src/main.tsx before `hydrateRoot`, // outside React entirely, and it feeds nothing that the first render // consults: `useInstallOffer` starts every one of its flags at the value // that renders nothing and only reaches `canPromptNatively()` from an // effect. The prerendered markup and the first client render stay // identical whatever localStorage happens to say. if (!isOfferSilenced(readInstallState())) event.preventDefault(); deferred = event as BeforeInstallPromptEvent; notify(); });
window.addEventListener("appinstalled", () => { installed = true; deferred = null; notify(); });}
/** Subscribe to changes in what the two predicates below would return. */export function subscribeInstallability(listener: () => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); };}
/** Is there a live handle to the browser's own install dialog right now? */export function canPromptNatively(): boolean { return deferred !== null;}
/** * Has an install completed in this tab since it opened? * * Distinct from `isRunningInstalled()` in ./platform.ts, which asks whether * *this document* is the installed app. This one is about the browser tab that * did the installing and is still sitting there in a normal window afterwards * — where the offer must come down even though the display mode never changed. */export function wasInstalledThisSession(): boolean { return installed;}
/** * Show the browser's install dialog and wait for the answer. * * The handle is cleared before awaiting rather than after: `prompt()` spends * it either way, and a double-click on the install button must not reach a * second `prompt()` call on a used event. */export async function promptInstall(): Promise<InstallOutcome> { const event = deferred; if (event === null) return "unavailable"; deferred = null; notify();
try { await event.prompt(); const { outcome } = await event.userChoice; return outcome; } catch { // Chromium rejects if the dialog cannot be shown — most often because // something else already showed one for this event, which is a live // possibility now that a silenced offer lets the browser's own prompt // through (see `watchInstallability` above). Either way no dialog of ours // ever opened, and `unavailable` is how the caller is told: see // `costOfOutcome` in ./useInstallOffer.ts, where a dialog that never // appeared costs the student nothing. return "unavailable"; }}apps/web/src/install/install.test.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511/** * Tests for the install offer's policy: which platform gets which story, when * the offer is allowed to appear at all, and what it costs the student when it * does. * * The claims worth pinning are the ones that make this restraint rather than a * banner: * * - **an iPad is not a laptop.** iPadOS reports a Macintosh user agent, and an * iPad has no `beforeinstallprompt` to correct the mistake — so a string * match alone resolves to `hidden` and the one device this site's written * instructions exist for is the one device never shown them; * - **silence is a supported outcome.** Browsers whose install path we cannot * verify get nothing, not a guess; * - **no on the first load, and no forever after enough noes.** Both are * functions of stored counters, and both are the difference between an * offer and a nag; * - **being seen counts as being asked.** An offer nobody touches has to back * off on its own, or the student who installed from the iOS steps — which * Safari has no way of telling us about — is asked again every visit; * - **a dialog that never opened is not a no.** Only an answer spends a * strike; * - **corrupt state under-asks.** Every unreadable value has to resolve * towards not interrupting, never towards interrupting; * - **a silenced offer gives the browser its prompt back.** Suppressing * `beforeinstallprompt` is only defensible while the page has an ask of its * own to make. * * The clock is injected throughout. A test that called `new Date()` would pass * today and fail in thirty-one days. */import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";import { costOfOutcome, describeOffer } from "./useInstallOffer";import { detectPlatform } from "./platform";import { DEFAULT_INSTALL_STATE, INSTALL_STATE_KEY, INSTALL_TUNING, isOfferDue, isOfferSilenced, readInstallState, recordDismissal, recordImpression, recordLoad, retireOffer, type InstallState,} from "./state";import type { StorageLike } from "../shorts/state";
const NOW = new Date("2026-06-01T00:00:00.000Z");
function daysBefore(days: number): string { return new Date(NOW.getTime() - days * 86_400_000).toISOString();}
/** An in-memory `StorageLike`, the same shape the shorts state tests use. */function memoryStorage( seed?: string,): StorageLike & { raw: () => string | null } { let value: string | null = seed ?? null; return { getItem: () => value, setItem: (_key, next) => { value = next; }, raw: () => value, };}
const UA = { iphone: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", chromeOnIphone: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0 Mobile/15E148 Safari/604.1", ipadOs: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", mac: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", android: "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36", chromebook: "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", windows: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",} as const;
describe("detectPlatform", () => { it("reads the obvious platforms off the user agent", () => { expect(detectPlatform(UA.iphone, 5)).toBe("ios"); expect(detectPlatform(UA.android, 5)).toBe("android"); expect(detectPlatform(UA.windows, 0)).toBe("desktop"); expect(detectPlatform(UA.chromebook, 0)).toBe("desktop"); });
it("separates an iPad from a Mac by touch points, not by string", () => { // Byte-identical user agents; only the touch count differs. This is the // entire reason `maxTouchPoints` is a parameter. expect(UA.ipadOs).toContain("Macintosh"); expect(detectPlatform(UA.ipadOs, 5)).toBe("ios"); expect(detectPlatform(UA.mac, 0)).toBe("desktop"); });
it("treats Chrome on iOS as iOS", () => { // Still WebKit underneath, so still no `beforeinstallprompt` — the browser // name in the UA is the one thing that must not matter here. expect(detectPlatform(UA.chromeOnIphone, 5)).toBe("ios"); });
it("falls back to `other` rather than guessing at an unknown agent", () => { expect(detectPlatform("", 0)).toBe("other"); expect(detectPlatform("SomeCrawler/1.0", 0)).toBe("other"); });});
describe("describeOffer", () => { it("prefers the browser's own dialog wherever one exists", () => { expect(describeOffer("android", true)).toEqual({ kind: "native", platform: "android", }); expect(describeOffer("desktop", true)).toEqual({ kind: "native", platform: "desktop", }); });
it("falls back to written steps on iOS, which has no dialog", () => { expect(describeOffer("ios", false)).toEqual({ kind: "manual", platform: "ios", }); });
it("says nothing on platforms whose install path we cannot verify", () => { // Every non-Chromium desktop browser lands here, `desktop` included: the // missing event is what identifies it. Wrong instructions cost more than // no instructions. expect(describeOffer("other", false)).toEqual({ kind: "hidden" }); expect(describeOffer("desktop", false)).toEqual({ kind: "hidden" }); expect(describeOffer("android", false)).toEqual({ kind: "hidden" }); });});
describe("costOfOutcome", () => { it("charges a strike only for an answer the student actually gave", () => { // `unavailable` is the one that matters: no dialog was ever shown, so // there is nothing to have said no to. Spending a strike and thirty days // on it would bill a student for a button that did nothing. expect(costOfOutcome("accepted")).toBe("retire"); expect(costOfOutcome("dismissed")).toBe("snooze"); expect(costOfOutcome("unavailable")).toBe("close"); });});
describe("isOfferDue", () => { function state(overrides: Partial<InstallState> = {}): InstallState { return { ...DEFAULT_INSTALL_STATE, loads: INSTALL_TUNING.MIN_LOADS, ...overrides, }; }
it("stays quiet on a first visit", () => { expect(isOfferDue(state({ loads: 1 }), NOW)).toBe(false); expect(isOfferDue(state({ loads: INSTALL_TUNING.MIN_LOADS }), NOW)).toBe( true, ); });
it("holds the snooze, then lets it lapse", () => { const days = INSTALL_TUNING.SNOOZE_MS / 86_400_000; expect( isOfferDue(state({ dismissals: 1, askedAt: daysBefore(days - 1) }), NOW), ).toBe(false); expect( isOfferDue(state({ dismissals: 1, askedAt: daysBefore(days + 1) }), NOW), ).toBe(true); });
it("snoozes on having been shown, with no dismissal at all", () => { // `dismissals` is 0 — nobody waved anything away — and the offer is quiet // regardless, because it was seen. This is what stops an offer that is // only ever ignored from arriving on every load. const days = INSTALL_TUNING.SNOOZE_MS / 86_400_000; expect(isOfferDue(state({ askedAt: daysBefore(1) }), NOW)).toBe(false); expect(isOfferDue(state({ askedAt: daysBefore(days + 1) }), NOW)).toBe( true, ); });
it("stops asking for good once the strikes are spent", () => { // Long past the snooze — the dismissal count is what ends it, not the clock. expect( isOfferDue( state({ dismissals: INSTALL_TUNING.MAX_DISMISSALS, askedAt: daysBefore(3650), }), NOW, ), ).toBe(false); });
it("reads an unparseable timestamp as a live asking", () => { // The conservative direction: a value we did not write means we cannot // prove the snooze has lapsed, so it has not. expect( isOfferDue(state({ dismissals: 1, askedAt: "yesterday" }), NOW), ).toBe(false); });});
describe("isOfferSilenced", () => { // The half of the rule that ./deferredPrompt.ts consults, which is the half // that does not care how many times the site has been opened. it("ignores the load count that `isOfferDue` gates on", () => { const fresh: InstallState = { ...DEFAULT_INSTALL_STATE, loads: 0 }; expect(isOfferSilenced(fresh, NOW)).toBe(false); expect(isOfferDue(fresh, NOW)).toBe(false); });
it("is true through the snooze and forever after the strikes", () => { expect( isOfferSilenced( { ...DEFAULT_INSTALL_STATE, askedAt: daysBefore(1) }, NOW, ), ).toBe(true); expect( isOfferSilenced( { ...DEFAULT_INSTALL_STATE, askedAt: daysBefore(31) }, NOW, ), ).toBe(false); expect( isOfferSilenced( { ...DEFAULT_INSTALL_STATE, dismissals: INSTALL_TUNING.MAX_DISMISSALS, askedAt: daysBefore(3650), }, NOW, ), ).toBe(true); });});
describe("readInstallState", () => { it("reads a payload that is no record at all as a fresh browser", () => { // The one place this module does not resolve towards silence, and the // reason is that there is nothing to be conservative *about*: an absent // key and unparseable JSON are exactly what a browser that has never been // here looks like, and it still owes `MIN_LOADS` before anything happens. // An array gets the same treatment for the same reason — it carries none // of the three fields, so there is no answer in it to preserve. expect(readInstallState(null)).toEqual(DEFAULT_INSTALL_STATE); expect(readInstallState(memoryStorage())).toEqual(DEFAULT_INSTALL_STATE); expect(readInstallState(memoryStorage("{oh no"))).toEqual( DEFAULT_INSTALL_STATE, ); expect(readInstallState(memoryStorage("[]"))).toEqual( DEFAULT_INSTALL_STATE, ); expect(readInstallState(memoryStorage("null"))).toEqual( DEFAULT_INSTALL_STATE, ); });
it("resolves values of the wrong type towards not asking", () => { // Not towards defaults — towards silence, which is the opposite direction. // A `dismissals` we cannot read is read as spent rather than as a fresh // set of three, and a timestamp we cannot read starts a snooze rather than // dropping the one that may have been there. const storage = memoryStorage( JSON.stringify({ loads: "many", askedAt: 12, dismissals: null }), ); expect(readInstallState(storage, NOW)).toEqual({ loads: 0, askedAt: NOW.toISOString(), dismissals: INSTALL_TUNING.MAX_DISMISSALS, }); expect(isOfferDue(readInstallState(storage, NOW), NOW)).toBe(false); expect(isOfferSilenced(readInstallState(storage, NOW), NOW)).toBe(true); });
it("keeps a missing field on the same side as a mistyped one", () => { // This module always writes all three, so an absent field is not "never // asked" — it is a record of unknown provenance, and gets the same // treatment. Only an explicit `null` is trusted as "never asked". expect(readInstallState(memoryStorage("{}"), NOW)).toEqual({ loads: 0, askedAt: NOW.toISOString(), dismissals: INSTALL_TUNING.MAX_DISMISSALS, }); });
it("clamps counters a hand edit could have inflated", () => { const storage = memoryStorage( JSON.stringify({ loads: 1e9, askedAt: null, dismissals: 900 }), ); const read = readInstallState(storage, NOW); expect(read.loads).toBe(INSTALL_TUNING.LOADS_CAP); expect(read.dismissals).toBe(INSTALL_TUNING.MAX_DISMISSALS); });
it("floors a negative or fractional count to zero-or-sane", () => { const storage = memoryStorage( JSON.stringify({ loads: -4, askedAt: null, dismissals: 1.9 }), ); expect(readInstallState(storage, NOW)).toEqual({ loads: 0, askedAt: null, dismissals: 1, }); });});
describe("the counters", () => { let storage: StorageLike & { raw: () => string | null };
beforeEach(() => { storage = memoryStorage(); });
it("counts loads and stops at the cap", () => { expect(recordLoad(storage).loads).toBe(1); expect(recordLoad(storage).loads).toBe(2); for (let i = 0; i < INSTALL_TUNING.LOADS_CAP + 5; i++) recordLoad(storage); expect(readInstallState(storage).loads).toBe(INSTALL_TUNING.LOADS_CAP); });
it("spends one strike per dismissal and stamps the clock", () => { const after = recordDismissal(NOW, storage); expect(after.dismissals).toBe(1); expect(after.askedAt).toBe(NOW.toISOString()); });
it("starts the snooze on being shown, and spends nothing", () => { recordLoad(storage); recordLoad(storage); const after = recordImpression(NOW, storage);
expect(after.askedAt).toBe(NOW.toISOString()); expect(after.dismissals).toBe(0); expect(after.loads).toBe(2);
// Quiet tomorrow, back in a month, and never having counted as a no — // which is what keeps an offer that is only ever ignored from being three // strikes down before the student has said anything. expect(isOfferDue(readInstallState(storage, NOW), NOW)).toBe(false); const later = new Date(NOW.getTime() + INSTALL_TUNING.SNOOZE_MS + 1); expect(isOfferDue(readInstallState(storage, later), later)).toBe(true); });
it("lets three shown-and-ignored offers still leave all three strikes", () => { // The hard stop belongs to dismissals. An offer that is shown and left // alone backs off each time but never runs the counter down. for (let i = 0; i < 3; i++) { recordImpression( new Date(NOW.getTime() + i * INSTALL_TUNING.SNOOZE_MS), storage, ); } expect(readInstallState(storage, NOW).dismissals).toBe(0); });
it("preserves the load count across a dismissal", () => { recordLoad(storage); recordLoad(storage); expect(recordDismissal(NOW, storage).loads).toBe(2); });
it("retires the offer by spending every remaining strike", () => { // One predicate reading one number: "installed" and "asked three times" // have to behave identically forever. retireOffer(storage); expect(readInstallState(storage, NOW).dismissals).toBe( INSTALL_TUNING.MAX_DISMISSALS, ); expect(isOfferDue(readInstallState(storage, NOW), NOW)).toBe(false); });
it("writes under the versioned key", () => { recordLoad(storage); expect(INSTALL_STATE_KEY).toBe("apbio.install.v1"); expect(storage.raw()).not.toBeNull(); });
it("persists nothing without storage, so the offer never comes due", () => { // The returned state is what *would* have been stored, but nothing is — // so a browser with storage blocked counts load 1 forever and, by // `MIN_LOADS`, is never interrupted. Under-asking is the right failure. expect(recordLoad(null)).toEqual({ loads: 1, askedAt: null, dismissals: 0, }); expect(recordLoad(null)).toEqual({ loads: 1, askedAt: null, dismissals: 0, }); expect(isOfferDue(recordLoad(null), NOW)).toBe(false); expect(recordDismissal(NOW, null)).toEqual({ loads: 0, askedAt: NOW.toISOString(), dismissals: 1, }); });});
/** * The one decision this feature makes outside React, and the one whose cost * lands on a student who will never see this site's panel again. * * vitest's node environment has no `window` to listen on and no `localStorage` * for the policy read, so both are stubbed for the length of this block and * taken away again after each case. `EventTarget` and a cancelable `Event` are * Node's own, which is what makes `defaultPrevented` — the entire assertion * here — a real answer rather than a mock agreeing with itself. */describe("watchInstallability", () => { // `global`, not `globalThis`: the browser-compat advisory lint is scoped by // path to everything under apps/*/src and cannot tell that a `.test.ts` is // never bundled, so `globalThis` would raise an ES2020 warning about a // browser this file never runs in. Node's own name for the same object is // exactly as true here and says out loud that this only runs under vitest. const globals = global as unknown as { window?: unknown; localStorage?: unknown; };
/** Seed storage, load a fresh copy of the module, fire the event at it. */ async function fireBeforeInstallPrompt(stored: Partial<InstallState> | null) { let raw: string | null = stored === null ? null : JSON.stringify({ ...DEFAULT_INSTALL_STATE, ...stored });
const target = new EventTarget(); globals.window = target; globals.localStorage = { getItem: () => raw, setItem: (_key: string, next: string) => { raw = next; }, };
// The module holds one deferred event and one "already watching" flag for // the life of the document, so every case needs its own copy of it. vi.resetModules(); const module = await import("./deferredPrompt"); module.watchInstallability();
const event = Object.assign( new Event("beforeinstallprompt", { cancelable: true }), { prompt: () => Promise.resolve(), userChoice: Promise.resolve({ outcome: "accepted" as const }), }, ); target.dispatchEvent(event); return { event, module }; }
afterEach(() => { delete globals.window; delete globals.localStorage; });
it("suppresses the browser's own prompt while the offer is still live", async () => { const { event, module } = await fireBeforeInstallPrompt(null); expect(event.defaultPrevented).toBe(true); expect(module.canPromptNatively()).toBe(true); });
it("hands the browser its prompt back once the strikes are spent", async () => { // The failure this is here for: a student who said no three times, or who // installed, used to keep a listener that ate `beforeinstallprompt` on // every load forever — leaving them with less install UI than someone who // had never opened the site. const { event, module } = await fireBeforeInstallPrompt({ dismissals: INSTALL_TUNING.MAX_DISMISSALS, }); expect(event.defaultPrevented).toBe(false); // Captured all the same. The handle costs nothing to hold and cannot be // got back, and only the suppression was ever conditional. expect(module.canPromptNatively()).toBe(true); });
it("hands it back during the snooze as well", async () => { // Real clock rather than the injected `NOW`, because the module reads its // own: a timestamp of "this instant" is inside a thirty-day snooze on any // day this test is ever run. const { event } = await fireBeforeInstallPrompt({ askedAt: new Date().toISOString(), }); expect(event.defaultPrevented).toBe(false); });
it("spends the handle on the first prompt and reports the answer", async () => { const { module } = await fireBeforeInstallPrompt(null); await expect(module.promptInstall()).resolves.toBe("accepted"); expect(module.canPromptNatively()).toBe(false); // A second caller — a double-click on the install button — gets // `unavailable` rather than a rejection from a spent event. By // `costOfOutcome` that charges the student nothing, which is the point: // no dialog opened the second time. await expect(module.promptInstall()).resolves.toBe("unavailable"); });});apps/web/src/install/platform.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112/** * Which install story the browser in front of us actually has. * * There are only two, and the difference is the whole reason this module * exists: * * - **Chromium** fires `beforeinstallprompt`, which hands the page a deferred * handle to the browser's own install dialog. One button, one tap, done. * Nothing here has to know what the browser's menus look like. * - **iOS Safari** does not, and never has. Installing is Share → Add to Home * Screen → Add, performed by hand in browser chrome the page cannot reach * or point at. The only thing a web page can do is *say so* — which is why * this site carries install instructions at all rather than just a button. * * Everything else (Firefox, desktop Safari) is deliberately left alone: see * `describeOffer` in ./useInstallOffer.ts for why guessing at a menu path we * cannot verify is worse than staying quiet. * * The inputs are parameters rather than reads of `navigator`, so this stays * importable under the prerender (no `navigator` there) and testable under * vitest's node environment (no `navigator` there either) — the same shape as * the storage handle in src/shorts/state.ts. */
/** Which set of install instructions, if any, applies. */export type InstallPlatform = "ios" | "android" | "desktop" | "other";
/** * Read the platform off a user-agent string. * * `maxTouchPoints` is not a nicety. Since iPadOS 13 an iPad reports itself as * `Macintosh; Intel Mac OS X` — the same UA a desktop Mac sends — so a string * match alone files every iPad under `desktop`. What that costs is silence, * not a wrong button: an iPad never fires `beforeinstallprompt`, so * `describeOffer` (./useInstallOffer.ts) resolves `desktop` with no native * handle to `hidden`, and the one device whose install path this site actually * spells out is the one device never shown it. A Mac with a touchscreen does * not exist, so "claims to be a Mac and has touch points" is the standard, and * reliable, tell. * * Order matters below: iOS is checked before Android and before desktop * because a Chrome-on-iOS UA contains `CriOS` on top of an iPhone string, and * it is still WebKit underneath, so it still has no `beforeinstallprompt`. */export function detectPlatform( userAgent: string, maxTouchPoints: number,): InstallPlatform { const isIosDevice = /iPhone|iPad|iPod/.test(userAgent); const isTouchMac = userAgent.includes("Macintosh") && maxTouchPoints > 1; if (isIosDevice || isTouchMac) return "ios";
if (userAgent.includes("Android")) return "android";
// Anything left that reads as a desktop OS. Not "not mobile" — an unknown // UA should land in `other` and be left alone, not be assumed to be a // laptop. if (/Windows|Macintosh|CrOS|X11|Linux/.test(userAgent)) return "desktop";
return "other";}
/** * `detectPlatform` against the live browser. Returns `other` off-browser. * * `maxTouchPoints` postdates BROWSER_FLOOR (packages/config/vite.ts) — Safari * 12 and iOS Safari 12.0-12.1 do not have it — and the line below is written * to be read straight through the property so the warn-only `compat/compat` * lint says so out loud on every run. (An inline `as Navigator & { ... }` cast * would type the fallback more honestly and hide the warning completely, which * is the wrong trade: the `?? 0` is the whole fallback, and the warning is the * only thing that will mention this line the next time the floor moves.) * * The property exists here for one job: telling an iPad that claims to be a * Macintosh apart from a real Mac. That claim started with iPadOS *13*. An iOS * 12 iPad still says `iPad` in its user agent and is caught by the string test * before the touch count is ever consulted, so the browsers missing this * property are exactly the browsers that never needed it — which is why `?? 0` * costs nothing rather than mis-filing every old iPad as a desktop. */export function currentPlatform(): InstallPlatform { if (typeof navigator === "undefined") return "other"; return detectPlatform(navigator.userAgent, navigator.maxTouchPoints ?? 0);}
/** * Is this document already the installed app? * * Two checks because the two platforms answer in different places. The * `display-mode` media query is the standard one and matches the * `"display": "standalone"` in public/manifest.webmanifest. `navigator.standalone` * is Safari's own non-standard flag, and on older iOS it is the only one that * answers — which is precisely the platform whose instructions are the most * annoying to be shown after you have already followed them. * * A false negative here costs a student one dismissal. A false positive means * the offer never appears at all, so both checks are ORed rather than ANDed. */export function isRunningInstalled(): boolean { if (typeof window === "undefined") return false;
try { if (window.matchMedia("(display-mode: standalone)").matches) return true; } catch { // `matchMedia` with an unparseable query throws in older WebKit rather // than returning a non-matching list. }
return ( (navigator as Navigator & { standalone?: boolean }).standalone === true );}apps/web/src/install/state.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318/** * What the install offer remembers — which is entirely "have we already asked, * and how did that go". * * An install prompt is the one piece of UI on this site that interrupts. That * makes restraint the whole design, and restraint is a storage problem: the * offer has to know it is not the first page load, that it already had its say * three weeks ago, and that it has now been waved away enough times to stop * asking. None of that survives a reload without being written down. * * Three rules, in `isOfferDue` below: * * - **not on the first load.** A prompt to install a site you have looked at * for four seconds is a prompt for a site you have not decided you want. * Waiting for a second document load costs nothing and means every student * who sees this has come back at least once. * - **a long snooze, started by being seen.** Dismissal means "not now", not * "never" — but so does silence, and this offer cannot tell a student who * considered it and moved on from one who never looked at the bottom of the * page. Both have to end the asking for a while, or the panel arrives on * every single load until it is finally clicked, which on iOS is forever: * Safari has no `appinstalled` event and a tab cannot see a home-screen * copy of itself, so a student who follows the steps successfully would go * on being asked. `askedAt` is therefore stamped when the offer is shown * (`recordImpression`) as well as when it is dismissed. On a site opened * most school days a ten-day snooze would be a monthly nag; thirty days is * about one asking per term. * - **a hard stop.** Three dismissals is an answer. After that the offer is * gone for good and installing is left to the browser's own menu, which is * where it lives anyway — and which ./deferredPrompt.ts hands back by not * suppressing the browser's own prompt once `isOfferSilenced` is true. * * Only a dismissal spends a strike. Being ignored buys silence but never * counts as a no, because "tapped nothing" is not an answer to record. * * Same conventions as src/shorts/state.ts, for the same reasons: the version * is in the key (`apbio.install.v1`) rather than the payload, every read is * total — absent key, malformed JSON, a `dismissals` that is a string — and * the storage handle is an injected parameter so the module imports cleanly * under the prerender and under vitest, neither of which has `localStorage`. * * Total, and *directional*: within a record, every unreadable value resolves * towards not asking. A `dismissals` that is not a number reads as spent, an * `askedAt` that is not a string or `null` reads as a snooze that started just * now. A field we cannot read is not a field we may interrupt on the strength * of, and the failure it leaves — a student who installs from the browser's * own menu instead — is the one worth having. (The next `recordLoad` writes * that reading back, so a corrupt record settles into a stop rather than * flickering between one and the other.) * * The one thing that does *not* resolve that way is a payload that is no * record at all: an absent key, unparseable JSON, `null`, an array. Those read * as a browser that has never been here, because that is precisely what they * are indistinguishable from — and a browser that has never been here still * owes `MIN_LOADS` before anything can be asked of it. */import type { StorageLike } from "../shorts/state";
export const INSTALL_STATE_KEY = "apbio.install.v1";
export const INSTALL_TUNING = { /** Document loads that must have happened before the offer may appear. */ MIN_LOADS: 2, /** Stop counting past this — the only question is "more than MIN_LOADS". */ LOADS_CAP: 99, /** How long an asking — shown, or waved away — silences the offer. */ SNOOZE_MS: 30 * 24 * 60 * 60 * 1000, /** Dismissals after which the offer never returns. */ MAX_DISMISSALS: 3, /** * How long the page is left alone before the offer slides in. Long enough * that whatever the student opened the page for has been rendered, read, * and started on; short enough that it does not arrive over a scroll. */ APPEAR_DELAY_MS: 2200,} as const;
export interface InstallState { /** Document loads counted so far, capped at `LOADS_CAP`. */ readonly loads: number; /** * When the offer last took the student's attention — put on screen, or waved * away — ISO, or null if it never has. One timestamp rather than two because * both events buy exactly the same thing, silence for `SNOOZE_MS`, and two * fields with one meaning is two fields that can disagree. */ readonly askedAt: string | null; /** How many times it has been waved away. */ readonly dismissals: number;}
export const DEFAULT_INSTALL_STATE: InstallState = { loads: 0, askedAt: null, dismissals: 0,};
function resolveLocalStorage(): StorageLike | null { try { if (typeof localStorage === "undefined") return null; return localStorage; } catch { // Access itself throws in some privacy modes, before any read happens. return null; }}
/** * A non-negative integer, or 0 for anything else at all. * * Zero is the safe reading for `loads`, and only for `loads`: fewer visits * than the student has really made means the offer waits longer, which is the * direction everything in this module errs in. The other two fields need the * opposite treatment, below. */function readCount(value: unknown, cap: number): number { if (typeof value !== "number") return 0; if (!Number.isFinite(value) || value < 0) return 0; return Math.min(Math.floor(value), cap);}
/** * `dismissals`, resolving anything unreadable to a full house. * * The asymmetry with `readCount` is the whole point. A `dismissals` that is * not a plain non-negative number is a value this module did not write — a * hand edit, a half-finished write, another tab's idea of the schema — and the * two ways of guessing wrong are not equal. Reading it as 0 hands a student * who has already said no three times a fresh set of three interruptions. * Reading it as `MAX_DISMISSALS` costs a student who never dismissed anything * a panel, and leaves them installing from the browser's own menu — which, * once the offer is silenced, is exactly where ./deferredPrompt.ts stops * getting in the browser's way. */function readDismissals(value: unknown): number { if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { return INSTALL_TUNING.MAX_DISMISSALS; } return Math.min(Math.floor(value), INSTALL_TUNING.MAX_DISMISSALS);}
/** * `askedAt`, resolving anything unreadable to "just now". * * Same direction, in the field where it is cheap: an unreadable timestamp * starts a fresh snooze rather than dropping the one that was there, so the * worst a corrupt record can do is delay the next asking. `null` is the one * non-string trusted, because `null` is what this module writes for "never * asked" — an absent field is not that, it is a record of unknown provenance, * and it is treated like any other unreadable value. */function readAskedAt(value: unknown, now: Date): string | null { if (value === null) return null; if (typeof value === "string") return value; return now.toISOString();}
/** * `now` is a parameter for the same reason the storage handle is: the corrupt * reading above stamps a timestamp, and a test that had to guess at the clock * could not assert which one. */export function readInstallState( storage: StorageLike | null = resolveLocalStorage(), now: Date = new Date(),): InstallState { if (storage === null) return DEFAULT_INSTALL_STATE;
let raw: string | null; try { raw = storage.getItem(INSTALL_STATE_KEY); } catch { return DEFAULT_INSTALL_STATE; } if (raw === null) return DEFAULT_INSTALL_STATE;
let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return DEFAULT_INSTALL_STATE; } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { return DEFAULT_INSTALL_STATE; }
const record = parsed as Record<string, unknown>; return { loads: readCount(record.loads, INSTALL_TUNING.LOADS_CAP), askedAt: readAskedAt(record.askedAt, now), dismissals: readDismissals(record.dismissals), };}
export function writeInstallState( next: InstallState, storage: StorageLike | null = resolveLocalStorage(),): void { if (storage === null) return; try { storage.setItem(INSTALL_STATE_KEY, JSON.stringify(next)); } catch { // Quota, or a private-mode write failure. The offer simply behaves as if // this were a first visit next time, which is the failure we want: it // under-asks rather than over-asks. }}
/** * Count this document load and hand back the state that results. * * Returns the new state rather than void so the caller can decide in the same * breath whether the offer is due, without a second read that would have to * agree with this write. */export function recordLoad( storage: StorageLike | null = resolveLocalStorage(),): InstallState { const state = readInstallState(storage); const next: InstallState = { ...state, loads: Math.min(state.loads + 1, INSTALL_TUNING.LOADS_CAP), }; writeInstallState(next, storage); return next;}
/** * The offer was actually put in front of the student. Starts the snooze. * * No strike, because nothing has been answered: this is the record that the * asking *happened*, which is the only thing that stops an offer nobody ever * touches from arriving on every load until it is finally clicked. The three * strikes stay reserved for the student saying no in as many words. */export function recordImpression( at: Date = new Date(), storage: StorageLike | null = resolveLocalStorage(),): InstallState { const next: InstallState = { ...readInstallState(storage, at), askedAt: at.toISOString(), }; writeInstallState(next, storage); return next;}
/** The student waved the offer away. Restarts the snooze and spends a strike. */export function recordDismissal( at: Date = new Date(), storage: StorageLike | null = resolveLocalStorage(),): InstallState { const state = readInstallState(storage, at); const next: InstallState = { ...state, askedAt: at.toISOString(), dismissals: Math.min(state.dismissals + 1, INSTALL_TUNING.MAX_DISMISSALS), }; writeInstallState(next, storage); return next;}
/** * The offer is spent — the student installed, or told us no for the last time. * * Burning every remaining strike rather than adding a fourth field: "never ask * again" and "asked three times" want identical behaviour forever, and one * predicate that reads one number cannot disagree with itself. */export function retireOffer( storage: StorageLike | null = resolveLocalStorage(),): void { writeInstallState( { ...readInstallState(storage), dismissals: INSTALL_TUNING.MAX_DISMISSALS, }, storage, );}
/** * Has this offer already had its turn? * * The snooze and the hard stop — the two rules that are about what the student * has already been shown and already said — with the load count deliberately * left out. That is exactly the question ./deferredPrompt.ts has to answer at * `beforeinstallprompt` time, before it decides whether it is entitled to take * the browser's own install prompt away from them, and it cannot use * `isOfferDue`: this visit's load has not been counted yet at that point (the * hook counts it, from an effect), so a returning student would look one load * short and the browser would be freed to ask on the very visit the page is * about to ask on. */export function isOfferSilenced( state: InstallState, now: Date = new Date(),): boolean { if (state.dismissals >= INSTALL_TUNING.MAX_DISMISSALS) return true; if (state.askedAt === null) return false;
const askedAt = Date.parse(state.askedAt); // A timestamp we cannot parse is one we did not write. Treat it as an asking // that has not expired rather than as no asking at all: the conservative // reading of "we asked once" is "do not ask again yet". if (Number.isNaN(askedAt)) return true;
return now.getTime() - askedAt < INSTALL_TUNING.SNOOZE_MS;}
/** Have the three rules in the module comment all been satisfied? */export function isOfferDue( state: InstallState, now: Date = new Date(),): boolean { if (state.loads < INSTALL_TUNING.MIN_LOADS) return false; return !isOfferSilenced(state, now);}apps/web/src/install/useInstallOffer.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209/** * The decision: offer to install, and if so, how. * * Everything the offer depends on is a client fact — the user agent, the * display mode, localStorage, an event Chromium may or may not have fired — * and this page is prerendered. So nothing here is read during render. The * hook returns `hidden` on its first render, always, on the server and on the * client alike, and only then does an effect look at the world and decide. * Same discipline and same reason as `useSavedClips` in src/shorts/saved.ts: * the first client render has to reproduce the build's markup exactly, and a * render that consults storage cannot. */import { useCallback, useEffect, useState } from "react";import { canPromptNatively, promptInstall, subscribeInstallability, wasInstalledThisSession, type InstallOutcome,} from "./deferredPrompt";import { currentPlatform, isRunningInstalled, type InstallPlatform,} from "./platform";import { INSTALL_TUNING, isOfferDue, recordDismissal, recordImpression, recordLoad, retireOffer,} from "./state";
export type InstallOffer = /** Say nothing. */ | { readonly kind: "hidden" } /** One button; the browser owns the dialog behind it. */ | { readonly kind: "native"; readonly platform: InstallPlatform } /** No dialog exists. All the page can do is describe the menu path. */ | { readonly kind: "manual"; readonly platform: "ios" };
const HIDDEN: InstallOffer = { kind: "hidden" };
/** * What to show, given the platform and whether a native handle exists. * * The last line is the interesting one, and it is deliberate silence. Note * what reaches it: not just `other`, but every `desktop` that never fired * `beforeinstallprompt` — which is how a non-Chromium laptop is recognised * here, since the event is a far better test of "has a dialog we can open" * than any user-agent string. Those browsers are of two kinds and both want * silence. Some have an install path we cannot name with confidence: desktop * Safari gained one in version 17 (Add to Dock), under a wording and in a menu * this page has no way to check. Others have none to name at all: desktop * Firefox ships no install command on the release channel — the Firefox that * does is the Android one, whose UA lands in `android` and gets a real button * the moment the event arrives. Instructions that name a menu item the student * cannot find are worse than no instructions, because they cost the trust that * would have made the *right* explanation land. So Chromium gets a real * button; iOS gets steps that have been the same for a decade; everyone else * is left with the browser's own install affordance, which they already have — * and which ./deferredPrompt.ts is careful not to suppress on their behalf. * * Pure and exported for the tests — this is the whole policy in three lines. */export function describeOffer( platform: InstallPlatform, hasNativePrompt: boolean,): InstallOffer { if (hasNativePrompt) return { kind: "native", platform }; if (platform === "ios") return { kind: "manual", platform }; return HIDDEN;}
/** What answering the browser's own dialog costs the student. */export type OutcomeCost = /** Installed. The offer is done for good. */ | "retire" /** A real no. A strike, and the snooze in ./state.ts. */ | "snooze" /** Take the panel down for this session and charge nothing. */ | "close";
/** * Which of those three an outcome earns. * * The one worth spelling out is `unavailable`, which is not an answer at all: * `promptInstall` reports it when the handle was already spent or the browser * refused to open the dialog, so nothing was ever put in front of the student * to answer. Recording that as a dismissal — a strike, plus thirty days of * silence — would charge them for a decision they were never offered, and from * where they sit the only visible event was a button that did nothing. The * panel still comes down, because a button that does nothing is worse on * screen than off it, but it costs no more than this session. * * Pure and exported for the tests, like `describeOffer` above. */export function costOfOutcome(outcome: InstallOutcome): OutcomeCost { if (outcome === "accepted") return "retire"; if (outcome === "dismissed") return "snooze"; return "close";}
export interface InstallOfferControls { readonly offer: InstallOffer; /** Run the browser's install dialog. Only meaningful for a native offer. */ readonly install: () => void; /** Not now. Starts the snooze in ./state.ts. */ readonly dismiss: () => void;}
export function useInstallOffer(): InstallOfferControls { // `due` gates on storage and the clock; `platform` and `hasNativePrompt` on // the browser. All three start at the value that renders nothing, so the // first client render matches the prerendered markup. const [due, setDue] = useState(false); const [platform, setPlatform] = useState<InstallPlatform>("other"); const [hasNativePrompt, setHasNativePrompt] = useState(false); const [closed, setClosed] = useState(false);
useEffect(() => { // Already the installed app: nothing to offer, and — importantly — no // load counted. Otherwise every launch from the home screen would spend // the counter that a browser visit is supposed to be earning. if (isRunningInstalled()) return;
setPlatform(currentPlatform()); if (!isOfferDue(recordLoad())) return;
const timer = setTimeout(() => { setDue(true); }, INSTALL_TUNING.APPEAR_DELAY_MS); return () => { clearTimeout(timer); }; }, []);
useEffect(() => { const sync = () => { setHasNativePrompt(canPromptNatively()); // The tab that just installed the app is still a normal browser tab, so // no display-mode check will ever notice. `appinstalled` is the only // signal, and the offer has to come down on it. if (wasInstalledThisSession()) { setClosed(true); retireOffer(); } }; // Once up front: `beforeinstallprompt` fires before hydration far more // often than not (see ./deferredPrompt.ts), so by now the subscription is // usually too late and only this read finds anything. sync(); return subscribeInstallability(sync); }, []);
const install = useCallback(() => { void promptInstall().then((outcome) => { // Three outcomes, three prices — see `costOfOutcome` above. "dismissed" // is a no to the browser's dialog and not to the site, so it snoozes // rather than retiring; "unavailable" means no dialog ever opened, and // is charged for accordingly, which is to say not at all. The panel // comes down in every case: the handle is spent either way, and a button // that can no longer do anything should not be sitting there. const cost = costOfOutcome(outcome); if (cost === "retire") retireOffer(); else if (cost === "snooze") recordDismissal(); setClosed(true); }); }, []);
const dismiss = useCallback(() => { recordDismissal(); setClosed(true); }, []);
const offer = due && !closed ? describeOffer(platform, hasNativePrompt) : HIDDEN;
// Being shown is itself an asking, and has to be written down as one. // // Nothing else would record it. The timestamp in ./state.ts is otherwise // stamped only by the X, by Escape, or by an answer to the browser's dialog, // so an offer that is simply left alone would return on every single load — // and on iOS, where there is no `appinstalled` event and a Safari tab cannot // see the home-screen copy of itself (see `isRunningInstalled` in // ./platform.ts), that includes the student who followed all three steps and // installed it successfully. They would be asked again every visit for the // rest of the year unless they thought to dismiss a panel they had obeyed. // // Keyed on `offer.kind` rather than on `due`, because those are different // claims: `due` says storage and the clock permit an asking, while a `kind` // other than `hidden` says one resolved to something to put on screen. A // desktop browser that is due but has no native handle shows nothing, and // must not be recorded as having asked. The one gap left is ./InstallOffer.tsx // holding the panel back while the shorts overlay is open — that visit is // counted as an asking it did not get, and the cost is one delayed offer. // // The effect is where this belongs, not the render that computed `offer`: // the first client render has to match the prerendered markup, and the write // is a side effect either way. const shown = offer.kind !== "hidden"; useEffect(() => { if (!shown) return; recordImpression(); }, [shown]);
return { offer, install, dismiss };}apps/web/src/main.tsx
123456789101112131415161718import { hydrateRoot } from "react-dom/client";import { BrowserRouter } from "react-router";import { App } from "./App";import { watchInstallability } from "./install/deferredPrompt";import { registerServiceWorker } from "./registerServiceWorker";import "./styles.css";
// Before `hydrateRoot`, not after, and not in a component effect: Chromium// fires `beforeinstallprompt` once, early, and routinely before React is up.// A listener that misses it loses the site's install button for the whole// visit — the browser's own prompt then goes ahead, which is the fallback but// not the plan. The storage read this now makes is outside React and reaches// no render; see src/install/deferredPrompt.ts.watchInstallability();
const root = document.getElementById("root");if (root === null) throw new Error("missing #root element");
apps/web/src/pages/Layout.tsx
13 unmodified lines1415161718192097 unmodified lines11811912012112212312412512612712812913013 unmodified linesimport { CED_VERSION } from "@ap-bio/curriculum";import { Link, NavLink, Outlet } from "react-router";import { CellMark } from "../components/CellMark";import { InstallOffer } from "../install/InstallOffer";import { ShortsOverlayProvider } from "../shorts/ShortsOverlay";import { homePath, reviewPath, shortsPath, unitsPath } from "../routes";
97 unmodified lines </span> </div> </footer>
{/* In the layout for the same reason the feed is: it belongs to the site, not to a page, and a route change must not reset how many times it has decided not to ask. It renders nothing until an effect has looked at the platform and at what it already asked (src/install/useInstallOffer.ts), so it costs the prerender nothing. */} <InstallOffer /> </div> );}apps/web/src/styles.css
653 unmodified lines654655656657658659660661662657658659660664665661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776669670671777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814673674675676677678679815816817818819820138 unmodified lines95996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025653 unmodified lines transform-origin: center; }
@media (prefers-reduced-motion: reduce) { .nl-reveal, .nl-organelle, .nl-gauge-fill { animation: none; } /* The reduced-motion opt-out for everything above lives at the very bottom of this layer rather than here — see the "Reduced motion" section at the end of the file for why it has to. */
.nl-panel-link:hover, .nl-panel-link:focus-visible { /* --------------------------------------------------------------------------- * Install offer — the "add this to your home screen" strip * (src/install/InstallOffer.tsx) * * Docked to the bottom rather than the top for one reason: on the phones this * is aimed at, the bottom is where the thumb is and where the browser's own * Share button sits, so a panel telling you to reach for that button is * pointing at itself from a hand's width away. * * The dock is a full-width strip and the panel inside it is capped and * centred, which is what keeps a laptop from being handed a 1200px-wide * notification. `pointer-events: none` on the strip and `auto` on the panel: * without it the invisible gutters either side of a centred panel would * swallow clicks on whatever the page put down there. * * The bottom padding carries the safe-area inset directly rather than using * `.nl-safe-bottom`, because this element is `position: fixed` and so is * outside the shell those classes are applied to — it has to clear the home * indicator on its own. * * The panel is capped in height and scrolls internally. Unfolding the three * iOS steps roughly doubles it, and a strip pinned to `bottom: 0` with no * ceiling grows *upward*: on a phone held sideways the usable viewport is * around 300px, which the unfolded panel exceeds — headline, and the close * button with it, off the top edge, on the one platform that has no Escape key * to fall back on. The cap subtracts the dock's own bottom padding and the * home-indicator inset, then leaves the same 0.9rem of air at the top that the * strip keeps at its sides. * ------------------------------------------------------------------------ */
.nl-install-dock { position: fixed; inset-inline: 0; bottom: 0; /* Under the shorts overlay's 60 on purpose. The panel is also hidden outright while the feed is open — see `[data-open="false"]` below. */ z-index: 50; display: flex; justify-content: center; padding: 0 0.9rem calc(0.9rem + env(safe-area-inset-bottom, 0px)); pointer-events: none; }
/* Covered by the feed. `visibility: hidden` rather than unmounting, for the same reason and by the same means as the overlay's own closed state: it is what takes the panel out of the tab order and off the accessibility tree, so there is no trap behind the lid, while leaving the disclosure open and the entrance animation spent — swiping the feed in over half-followed instructions and swiping it back out returns the student to the step they were on rather than to a re-animated, re-folded panel. */ .nl-install-dock[data-open="false"] { visibility: hidden; }
.nl-install-panel { pointer-events: auto; width: 100%; max-width: 27rem; padding: 1rem 1.1rem; /* A column so the scrolling region below can be told to shrink. */ display: flex; flex-direction: column; /* `vh` first, then `dvh` — the same restatement, for the same reason, as the shorts feed's card heights; see the `dvh` bullet in the Shorts comment below. A floor browser (chrome70/safari12) drops the line it cannot parse and keeps the `vh` one, which on a phone measures the viewport with the URL bar hidden and so caps slightly generously; a current browser keeps both and `dvh` wins. */ max-height: calc(100vh - 1.8rem - env(safe-area-inset-bottom, 0px)); max-height: calc(100dvh - 1.8rem - env(safe-area-inset-bottom, 0px)); /* The housing is translucent everywhere else on the site; here it sits over live text, so the gradient is pushed to near-opaque and a blur is laid under it where the browser has one. */ background: linear-gradient( 180deg, color-mix(in oklab, var(--bench) 98%, transparent), color-mix(in oklab, var(--abyss) 97%, transparent) ); backdrop-filter: blur(14px); box-shadow: inset 0 1px 0 color-mix(in oklab, white 7%, transparent), 0 -6px 40px -18px black, 0 24px 48px -30px black; animation: nl-install-in 420ms cubic-bezier(0.16, 1, 0.3, 1) both; }
/* The overflow lives on an inner element rather than on the panel, because `.nl-panel` draws its corner ticks as pseudo-elements offset to -1px — outside the padding box, which is exactly the rectangle an `overflow` other than `visible` clips to. Scrolling the panel itself would shave the line off both ticks. Keeping the close button outside this element is the second reason: it stays pinned to the housing while the text moves under it, instead of scrolling away from a student who is reaching for it. */ .nl-install-scroll { /* A flex item's `min-height: auto` refuses to shrink below its content, which would let the steps push straight through the cap above as if it were not there. */ min-height: 0; overflow-y: auto; /* Keeps a flick at the end of the steps from scrolling the page behind. Same accepted BROWSER_FLOOR gap as the feed's — a browser that ignores it just chains the scroll, which is the behaviour there is today. */ overscroll-behavior-y: contain; }
@keyframes nl-install-in { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: none; } }
.nl-shorts-overlay { transition-duration: 0s; } /* Set on <html> while the offer is up. Below about 460px the panel is the full width of the screen, so anything the browser scrolls to the bottom edge — a link reached by Tab, an element VoiceOver moved to — lands underneath it, and no amount of further scrolling can clear it because the dock is fixed. Reserving the dock's measured height as scroll padding is what gives "scroll this into view" somewhere to stop (WCAG 2.2 SC 2.4.11). The height is measured rather than guessed: it changes when the steps unfold, and InstallOffer.tsx writes it here as `--nl-install-dock-h`.
`scroll-padding` postdates BROWSER_FLOOR's safari12, so on the floor the declaration is dropped and the obstruction is what it is today — an accepted gap, like the feed's `touch-action`, not a fallback candidate: there is no older property that reserves scrollport padding. */ .nl-install-open { scroll-padding-bottom: var(--nl-install-dock-h, 0px); }
/* Top-right — the one corner of the housing with no tick on it, since `.nl-panel` draws only the top-left and bottom-right pair, so the button never sits over the line. Sized to 44px of touch target with padding while drawing as a 14px glyph — the same trick the nav links use, and for the same reason. */ .nl-install-close { position: absolute; top: 0; right: 0; display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; color: var(--muted-foreground); background: transparent; border: 0; border-radius: var(--radius); transition: color 180ms ease; }
/* The card still turns over — the flip is what tells you the answer came off the back of this card rather than out of a new one — it just does it instantly instead of sweeping through 180°. */ .nl-card, .nl-box-seg { transition: none; } .nl-install-close:hover, .nl-install-close:focus-visible { color: var(--foreground); }
/* ---------------------------------------------------------------------------138 unmodified lines width: max(100dvw, calc(100dvh * var(--nl-clip-ar, 1.7778))); height: max(100dvh, calc(100dvw / var(--nl-clip-ar, 1.7778))); }
/* --------------------------------------------------------------------------- * Reduced motion — the one opt-out, and why it is the last thing in the layer. * * A media query adds nothing to specificity. `@media (prefers-reduced-motion) * { .nl-reveal { animation: none } }` and `.nl-reveal { animation: … }` are both * a single class, both in this layer, so the cascade decides between them on * source order alone and the *later* one wins. That worked by accident while * this block sat directly under the entrance animations — every selector in it * happened to be declared above it — and it broke silently the moment the * install panel was added further down the file: `.nl-install-panel` was listed * here, its animation was defined 70 lines later, and the animation won. The * one person on the site who had asked for no motion was the only person * getting an 18px slide over the page they were reading. * * Anchoring the block at the end of the layer makes that class of mistake * impossible rather than merely fixed: anything animated anywhere above can be * turned off from here, and a new animation added tomorrow lands above this * block no matter where in the file it goes. * * Specificity still has to be matched by hand, though — source order only * decides ties. Each selector below is written at the weight of the rule it * has to beat. * ------------------------------------------------------------------------ */
@media (prefers-reduced-motion: reduce) { .nl-reveal, .nl-organelle, .nl-gauge-fill, .nl-install-panel { animation: none; }
.nl-panel-link:hover, .nl-panel-link:focus-visible { transform: none; }
/* Both states of the feed, because the closed one restates the whole `transition` shorthand at `.nl-shorts-overlay[data-open="false"]` — higher specificity than a bare class can reach, so a single `.nl-shorts-overlay` rule here would leave the slide-*out* animating no matter where in the file it sat. `transition: none` rather than `transition-duration: 0s` for the same reason the shorthand is what needs beating: the closed rule also carries a 320ms delay on `visibility`, timed to the slide, and with no slide to wait for that delay is 320ms of an invisible panel still holding a place in the tab order. */ .nl-shorts-overlay, .nl-shorts-overlay[data-open="false"] { transition: none; }
/* The card still turns over — the flip is what tells you the answer came off the back of this card rather than out of a new one — it just does it instantly instead of sweeping through 180°. */ .nl-card, .nl-box-seg { transition: none; } }} /* end @layer components */
/* ---------------------------------------------------------------------------This view needs a browser with declarative shadow DOM: Chrome 111, Safari 16.4, or Firefox 123. Read the source instead.
clone
$ git clone https://git.fugl.dev/russ/ap-bioanonymous, no account$ git clone ssh://git.fugl.dev/russ/ap-bioneeds the bastion ProxyCommand