diff --git a/src/__tests__/pwa-mobile-shell-guard.test.ts b/src/__tests__/pwa-mobile-shell-guard.test.ts index 22558121e..d8edd594a 100644 --- a/src/__tests__/pwa-mobile-shell-guard.test.ts +++ b/src/__tests__/pwa-mobile-shell-guard.test.ts @@ -60,6 +60,15 @@ describe("the shell owns the safe area", () => { expect(css).toMatch(/@custom-variant pointer-fine/); }); + it("bottom toasts clear the bar under the same query the bar follows", () => { + // Sonner's own mobile offset stops at 600 px; the bar shows up to `md` + // and on a phone held sideways, so the lift keys on `shell-mobile`. + const css = code(read("src/app/globals.css")); + expect(css).toMatch( + /html \[data-sonner-toaster\]\[data-y-position="bottom"\]\s*\{\s*@variant shell-mobile\s*\{\s*bottom:\s*calc\(4rem \+ env\(safe-area-inset-bottom/, + ); + }); + it("the bottom bar and the Coach button step aside for the keyboard", () => { expect(read("src/components/layout/bottom-nav.tsx")).toContain( 'data-keyboard-hide=""', @@ -69,6 +78,51 @@ describe("the shell owns the safe area", () => { ); }); + it("a bottom sheet on the keyboard drops its home-indicator padding", () => { + // Standing on the keyboard, the safe-area inset left a ~34 px gap + // between the sheet's last row and the keys. + expect(code(read("src/app/globals.css"))).toMatch( + /@custom-variant keyboard-open \(html\[data-keyboard="open"\] &\);/, + ); + const sheet = code(read("src/components/ui/responsive-sheet.tsx")); + const padded = (sheet.match( + /"[^"]*pb-\[calc\(env\(safe-area-inset-bottom[^"]*"/g, + ) ?? []) as string[]; + expect(padded.length).toBe(2); + for (const cls of padded) expect(cls).toMatch(/\bkeyboard-open:pb-4\b/); + }); + + it("those rules sit under the utilities, and the bottom sheet sets no bottom utility", () => { + // Unlayered, the rules outranked every utility on the same element: a + // `bg-*` on the shell or a `bottom-*` on a sheet would have been ignored + // without a trace. In the components layer a utility wins, so the bottom + // sheet must not carry one that would pin it under the keyboard. + const css = code(read("src/app/globals.css")); + const start = css.indexOf("@layer components {"); + expect(start).toBeGreaterThan(-1); + let depth = 0; + let end = start; + for (let i = css.indexOf("{", start); i < css.length; i += 1) { + if (css[i] === "{") depth += 1; + else if (css[i] === "}" && --depth === 0) { + end = i; + break; + } + } + const layer = css.slice(start, end); + expect(layer).toMatch(/\.shell-safe-area\s*\{/); + expect(layer).toMatch(/\[data-sheet-side="bottom"\]\s*\{\s*bottom:/); + expect(layer).toMatch(/\[data-keyboard-hide\]\s*\{\s*display:\s*none/); + expect(layer).toMatch(/\[data-sheet-side="left"\]\s*\{\s*border-left:/); + // Sonner's own rules are unlayered; the toast lift must stay so. + expect(layer).not.toMatch(/data-sonner-toaster/); + + const sheet = code(read("src/components/ui/sheet.tsx")); + const bottomBranch = sheet.match(/side === "bottom" &&\s*"([^"]*)"/)?.[1]; + expect(bottomBranch).toBeDefined(); + expect(bottomBranch).not.toMatch(/(? { expect(read("src/components/ui/sheet.tsx")).toMatch( /data-sheet-side=\{side\}/, diff --git a/src/__tests__/retired-route-links-guard.test.ts b/src/__tests__/retired-route-links-guard.test.ts new file mode 100644 index 000000000..b5d11d6d5 --- /dev/null +++ b/src/__tests__/retired-route-links-guard.test.ts @@ -0,0 +1,43 @@ +/** + * Links point at a route that renders, not at a retired one that only + * redirects: every hop through a redirect is a round trip on a phone, and + * the redirect stubs exist for old bookmarks, not for the app's own links. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, "..", ".."); + +/** Retired route → where it redirects. */ +const RETIRED: Record = { + "/medications/new": "/medications?new=1", +}; + +function walk(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + if (name === "__tests__" || name === "generated") return []; + const abs = join(dir, name); + if (statSync(abs).isDirectory()) return walk(abs); + return /\.tsx?$/.test(name) ? [abs] : []; + }); +} + +describe("no link targets a retired route", () => { + const files = walk(join(ROOT, "src")); + + it("scans the source tree", () => { + expect(files.length).toBeGreaterThan(100); + }); + + it.each(Object.entries(RETIRED))("%s is linked as %s", (route) => { + const pattern = new RegExp( + `(?:href|push|replace)[=(]\\s*\\{?\\s*["'\`]${route.replace(/\//g, "\\/")}["'\`?#]`, + ); + const offenders = files + .filter((file) => pattern.test(readFileSync(file, "utf8"))) + .map((file) => relative(ROOT, file)); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/app/globals.css b/src/app/globals.css index 9f8b8e3b3..8006ffce5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -76,6 +76,13 @@ } } +/* + * `keyboard-open:` — while the on-screen keyboard is up (see the keyboard + * rules further down). A bottom sheet then stands on the keyboard, not on + * the home indicator, so its safe-area padding has nothing left to clear. + */ +@custom-variant keyboard-open (html[data-keyboard="open"] &); + @custom-variant shell-mobile { @media (max-width: 47.98rem), (pointer: coarse) and (max-height: 520px) { @slot; @@ -587,66 +594,92 @@ } /* - * `.shell-safe-area` — the authenticated app shell's share of the installed - * app's safe area (see the note in `auth-shell.tsx`). The shell takes the top - * and side insets as padding, and the strip behind the status bar is painted - * in the chrome colour (`--card`, the top bar and the sidebar) rather than - * the page colour, so the status bar reads as part of the bar beneath it. A - * zero inset makes the gradient zero tall: nothing changes in a browser tab. + * The shell's safe area, the on-screen keyboard and the sheet clearances sit + * in the components layer, under the utilities, so a utility on the same + * element still wins and these rules cannot silently override one. The + * bottom sheet takes its `bottom` from here alone for that reason (the + * sheet primitive sets no `bottom-*`); the keyboard rules that must beat a + * utility say `!important`, which in a layer outranks any utility. */ -.shell-safe-area { - padding-top: env(safe-area-inset-top, 0px); - padding-right: env(safe-area-inset-right, 0px); - padding-left: env(safe-area-inset-left, 0px); - background: linear-gradient(var(--card), var(--card)) top / 100% - env(safe-area-inset-top, 0px) no-repeat; +@layer components { + /* + * `.shell-safe-area` — the authenticated app shell's share of the installed + * app's safe area (see the note in `auth-shell.tsx`). The shell takes the top + * and side insets as padding, and the strip behind the status bar is painted + * in the chrome colour (`--card`, the top bar and the sidebar) rather than + * the page colour, so the status bar reads as part of the bar beneath it. A + * zero inset makes the gradient zero tall: nothing changes in a browser tab. + */ + .shell-safe-area { + padding-top: env(safe-area-inset-top, 0px); + padding-right: env(safe-area-inset-right, 0px); + padding-left: env(safe-area-inset-left, 0px); + background: linear-gradient(var(--card), var(--card)) top / 100% + env(safe-area-inset-top, 0px) no-repeat; + } + + /* + * The on-screen keyboard. iOS Safari, and Chrome on Android by default, + * shrink only the visual viewport when the keyboard opens: everything + * `position: fixed` stays anchored to the layout viewport, so a bottom sheet + * keeps its footer (Save, the Coach composer) behind the keyboard and the + * bottom bar floats somewhere under it. `useKeyboardInset()` (mounted once + * in the providers) publishes the covered height as `--keyboard-inset` and + * sets `data-keyboard` on `` while it is open. + * + * Bottom sheets then stand on the keyboard and give up the height it takes, + * so their own scroll body (every bottom sheet is a flex column with a + * scrolling middle) carries the form, and the bottom bar and the Coach + * button step aside until the keyboard is gone. + */ + [data-sheet-side="bottom"] { + bottom: var(--keyboard-inset, 0px); + } + html[data-keyboard="open"] [data-sheet-side="bottom"] { + max-height: calc( + 100dvh - var(--keyboard-inset, 0px) - env(safe-area-inset-top, 0px) - + 0.5rem + ) !important; + } + html[data-keyboard="open"] [data-keyboard-hide] { + display: none !important; + } + + /* + * Side and top sheets run the full height of the window. In an installed + * app on a tablet the status bar sits over their first 24 px, and a phone + * held sideways has its notch over one side, so the panel keeps its header + * clear of both. A transparent border rather than padding: every consumer + * sets its own padding (often `p-0`), which would override a padding here, + * while the border adds the clearance under any of them and lets the + * panel's own background show through it. + */ + [data-sheet-side="right"], + [data-sheet-side="left"], + [data-sheet-side="top"] { + border-top: env(safe-area-inset-top, 0px) solid transparent; + } + [data-sheet-side="right"] { + border-right: env(safe-area-inset-right, 0px) solid transparent; + } + [data-sheet-side="left"] { + border-left: env(safe-area-inset-left, 0px) solid transparent; + } } /* - * The on-screen keyboard. iOS Safari, and Chrome on Android by default, - * shrink only the visual viewport when the keyboard opens: everything - * `position: fixed` stays anchored to the layout viewport, so a bottom sheet - * keeps its footer (Save, the Coach composer) behind the keyboard and the - * bottom bar floats somewhere under it. `useKeyboardInset()` (mounted once - * in the providers) publishes the covered height as `--keyboard-inset` and - * sets `data-keyboard` on `` while it is open. - * - * Bottom sheets then stand on the keyboard and give up the height it takes, - * so their own scroll body (every bottom sheet is a flex column with a - * scrolling middle) carries the form, and the bottom bar and the Coach - * button step aside until the keyboard is gone. + * Toasts clear the bottom bar wherever the phone shell shows it. The + * toaster's `mobileOffset` (providers.tsx) applies below 600 px only, while + * the bar follows the shell (up to `md`, and a phone held sideways): between + * the two the "new version" toast sat over the tabs. Unlayered and one `html` + * deeper than Sonner's injected `[data-sonner-toaster][data-y-position]` + * rule, which is unlayered too and lands after this sheet. The Coach panel's + * rule further down still outranks it while that panel is open. */ -[data-sheet-side="bottom"] { - bottom: var(--keyboard-inset, 0px); -} -html[data-keyboard="open"] [data-sheet-side="bottom"] { - max-height: calc( - 100dvh - var(--keyboard-inset, 0px) - env(safe-area-inset-top, 0px) - 0.5rem - ) !important; -} -html[data-keyboard="open"] [data-keyboard-hide] { - display: none !important; -} - -/* - * Side and top sheets run the full height of the window. In an installed - * app on a tablet the status bar sits over their first 24 px, and a phone - * held sideways has its notch over one side, so the panel keeps its header - * clear of both. A transparent border rather than padding: every consumer - * sets its own padding (often `p-0`), which would override a padding here, - * while the border adds the clearance under any of them and lets the - * panel's own background show through it. - */ -[data-sheet-side="right"], -[data-sheet-side="left"], -[data-sheet-side="top"] { - border-top: env(safe-area-inset-top, 0px) solid transparent; -} -[data-sheet-side="right"] { - border-right: env(safe-area-inset-right, 0px) solid transparent; -} -[data-sheet-side="left"] { - border-left: env(safe-area-inset-left, 0px) solid transparent; +html [data-sonner-toaster][data-y-position="bottom"] { + @variant shell-mobile { + bottom: calc(4rem + env(safe-area-inset-bottom, 0px) + 0.75rem); + } } /* diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index e52e82600..b4b544dde 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -18,6 +18,7 @@ import { type MoodAggregates, } from "@/lib/insights/mood-aggregates"; import type { MoodDailySeries } from "@/lib/analytics/mood-series"; +import { settleWithin } from "@/lib/async/settle-within"; import { resolveModuleMap } from "@/lib/modules/gate"; import { queryKeys } from "@/lib/query-keys"; @@ -32,12 +33,7 @@ import InsightsMoodPageClient from "./page-client"; const PREFETCH_BUDGET_MS = 1_500; function withinBudget(work: Promise): Promise { - return Promise.race([ - work, - new Promise((resolve) => - setTimeout(() => resolve(null), PREFETCH_BUDGET_MS), - ), - ]).catch(() => null); + return settleWithin(work, PREFETCH_BUDGET_MS, null).catch(() => null); } /** diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8ce442359..57e112094 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,7 +9,7 @@ import { MonitoringBootstrap } from "@/components/monitoring/bootstrap"; import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter"; import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale"; import { isKeyMismatch } from "@/lib/boot/key-mismatch-state"; -import { THEME_COLOR } from "@/lib/pwa/theme-color"; +import { THEME_BOOT_SCRIPT, THEME_COLOR } from "@/lib/pwa/theme-color"; import { KeyMismatchPage } from "./key-mismatch-page"; const inter = Inter({ @@ -80,9 +80,6 @@ export const viewport: Viewport = { ], }; -// Inline script to apply theme before first paint (prevents FOUC) -const themeScript = `(function(){try{var t=localStorage.getItem("healthlog-theme");var c=(t==="light"||t==="dark")?t:(t==="system"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):"dark");document.documentElement.classList.add(c)}catch(e){document.documentElement.classList.add("dark")}})()`; - export default async function RootLayout({ children, }: Readonly<{ @@ -101,7 +98,9 @@ export default async function RootLayout({