Merge remote-tracking branch 'origin/release/v1.42.0' into v142/ui-audit

This commit is contained in:
Marc-André Bombeck committed 2026-10-08 17:44:00 +02:00
commit 492c2a4131
41 files changed
+1268 -151

No files matched your search

+239
View File
@@ -0,0 +1,239 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The installed-app behaviours a phone depends on, measured in the Pixel 5
* project (a touch screen: `pointer: coarse`).
*
* 1. The "new version" hint stands above the bottom bar instead of on it,
* and its Reload action reloads the page.
* 2. With the on-screen keyboard open, a bottom sheet stands on the
* keyboard (its Save button stays visible) and the bottom bar steps
* aside. Playwright has no on-screen keyboard, so the visual viewport is
* replaced by a stand-in whose height the test shrinks the way a
* keyboard does; what is asserted is the page's answer to that signal.
* 3. No form field a phone can focus is set below 16 px, in portrait or
* held sideways, because iOS Safari zooms the page into such a field.
* 4. Held sideways, the sign-in card is not centred out of reach.
*
* The account is the populated mobile-sweep account; nothing here writes.
*/
const VISUAL_VIEWPORT_STAND_IN = () => {
const stand = new EventTarget() as EventTarget & {
height: number;
width: number;
offsetTop: number;
offsetLeft: number;
pageTop: number;
pageLeft: number;
scale: number;
};
stand.height = window.innerHeight;
stand.width = window.innerWidth;
stand.offsetTop = 0;
stand.offsetLeft = 0;
stand.pageTop = 0;
stand.pageLeft = 0;
stand.scale = 1;
Object.defineProperty(window, "visualViewport", {
configurable: true,
get: () => stand,
});
(window as unknown as { __viewport: typeof stand }).__viewport = stand;
};
async function setKeyboard(page: Page, covered: number) {
await page.evaluate((px) => {
const stand = (
window as unknown as {
__viewport: EventTarget & { height: number };
}
).__viewport;
stand.height = window.innerHeight - px;
stand.dispatchEvent(new Event("resize"));
}, covered);
}
async function openCaptureForm(page: Page) {
await page.goto("/");
await page.getByTestId("bottom-nav-capture").click();
const picker = page.locator('[data-slot="responsive-sheet-content"]');
await expect(picker).toBeVisible();
await picker
.locator('[data-slot="responsive-sheet-body"] button')
.first()
.click();
const form = page
.locator('[data-slot="responsive-sheet-content"]')
.filter({ has: page.locator('[data-slot="responsive-sheet-footer"]') });
await expect(form).toBeVisible();
return form;
}
/** Every visible field a phone can focus, with its computed font size. */
async function fieldFontSizes(page: Page) {
return page.evaluate(() =>
Array.from(
document.querySelectorAll<HTMLElement>(
"input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),textarea,select",
),
)
.filter((el) => {
const r = el.getBoundingClientRect();
return r.width > 2 && r.height > 2;
})
.map((el) => ({
field:
el.getAttribute("name") ||
el.id ||
el.getAttribute("aria-label") ||
el.tagName,
px: parseFloat(getComputedStyle(el).fontSize),
})),
);
}
test.describe("installed app on a phone", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
test("the new-version hint stands above the bottom bar and reloads", async ({
page,
}) => {
await page.route("**/api/version", (route) =>
route.fulfill({
json: {
data: { version: "999.0.0", buildSha: "e2e" },
error: null,
},
}),
);
await page.goto("/");
const toast = page.locator("[data-sonner-toast]").filter({
has: page.getByRole("button", { name: "Reload" }),
});
// The poller's first check runs five seconds after mount.
await expect(toast).toBeVisible({ timeout: 15_000 });
const nav = page.locator('[data-slot="bottom-nav"]');
const navBox = (await nav.boundingBox())!;
// Polled: the toast slides in from below, so an early sample is
// mid-animation.
await expect
.poll(async () => {
const b = (await toast.boundingBox())!;
return b.y + b.height;
})
.toBeLessThanOrEqual(navBox.y);
// The bottom bar's controls are still reachable with the hint up.
await expect(page.getByTestId("bottom-nav-more")).toBeVisible();
const reloaded = page.waitForEvent("load");
await toast.getByRole("button", { name: "Reload" }).click();
await reloaded;
});
test("a bottom sheet stands on the on-screen keyboard and the bottom bar steps aside", async ({
page,
}) => {
await page.addInitScript(VISUAL_VIEWPORT_STAND_IN);
const form = await openCaptureForm(page);
const save = form
.locator('[data-slot="responsive-sheet-footer"] button')
.last();
const viewportHeight = page.viewportSize()!.height;
const bottomEdge = async () => {
const b = (await form.boundingBox())!;
return Math.round(b.y + b.height);
};
// Closed keyboard: the sheet sits on the bottom edge (polled: it slides
// in from below).
await expect.poll(bottomEdge).toBe(viewportHeight);
const keyboard = 300;
await setKeyboard(page, keyboard);
await expect(page.locator("html")).toHaveAttribute("data-keyboard", "open");
await expect.poll(bottomEdge).toBe(viewportHeight - keyboard);
const box = (await form.boundingBox())!;
expect(box.y).toBeGreaterThanOrEqual(0);
const saveBox = (await save.boundingBox())!;
expect(saveBox.y + saveBox.height).toBeLessThanOrEqual(
viewportHeight - keyboard,
);
await expect(page.locator('[data-slot="bottom-nav"]')).toBeHidden();
// A toolbar-sized change is not a keyboard.
await setKeyboard(page, 60);
await expect(page.locator("html")).not.toHaveAttribute("data-keyboard");
await expect.poll(bottomEdge).toBe(viewportHeight);
});
for (const viewport of [
{ width: 390, height: 844 },
{ width: 844, height: 390 },
]) {
test(`no focusable field is below 16 px at ${viewport.width}x${viewport.height} (iOS focus zoom)`, async ({
page,
}) => {
await page.setViewportSize(viewport);
const offenders: string[] = [];
for (const path of [
"/settings",
"/documents",
"/settings/notifications",
]) {
await page.goto(path);
await page.waitForLoadState("networkidle");
const fields = await fieldFontSizes(page);
expect(fields.length, `${path} renders no field`).toBeGreaterThan(0);
for (const f of fields)
if (f.px < 16) offenders.push(`${path} ${f.field} ${f.px}px`);
}
if (viewport.width < 768) {
const form = await openCaptureForm(page);
await expect(form.locator("input").first()).toBeVisible();
for (const f of await fieldFontSizes(page))
if (f.px < 16) offenders.push(`capture ${f.field} ${f.px}px`);
}
expect(offenders).toEqual([]);
});
}
});
test.describe("signed out, held sideways", () => {
test.use({ viewport: { width: 844, height: 390 } });
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
test("the sign-in card stays reachable from its top edge", async ({
page,
}) => {
await page.goto("/auth/login");
const card = page.locator("main#main-content > *").first();
await expect(card).toBeVisible();
// Open the password form, the tallest state of the card.
await page
.getByRole("button", { name: /password|passwort/i })
.first()
.click();
await expect(page.locator("input[type=password]").first()).toBeVisible();
const box = (await card.boundingBox())!;
expect(box.y).toBeGreaterThanOrEqual(0);
// And the document scrolls to reach whatever lies below the fold.
const scrollable = await page.evaluate(
() =>
document.documentElement.scrollHeight >= window.innerHeight &&
getComputedStyle(document.documentElement).overflowY !== "hidden",
);
expect(scrollable).toBe(true);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* What the installed app shows without a connection, with the service worker
* actually running.
*
* Runs in the `chromium-service-worker` project, the one that lets the worker
* register (every other project blocks it; see `playwright.config.ts`), and
* like the other spec there it uses no route mocks: the claim is about what
* the worker itself serves.
*
* 1. A page that is open when the connection drops stays the app and says
* so (the offline strip).
* 2. Opening a page without a connection gets the worker's offline page:
* the word "Offline" and a working retry control, never the browser's
* error. (Signed-in pages are never cached, by design: the proxy pins
* `private, no-store` on them because some carry the record in the
* HTML. So this is what an offline launch of the installed app shows.)
* 3. When the connection returns, that page reloads into the app by
* itself.
*
* The account is the populated mobile-sweep account; nothing here writes.
*/
test.describe("offline, with the service worker", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test("an open page says it is offline, a new one opens the offline page, and both recover", async ({
page,
context,
}) => {
await page.goto("/medications");
await page.waitForFunction(
() => navigator.serviceWorker?.controller !== null,
undefined,
{ timeout: 20_000 },
);
await expect(page.locator("#main-content")).toBeVisible();
await page.waitForLoadState("networkidle");
try {
await context.setOffline(true);
await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible();
const response = await page.goto("/timeline");
expect(response?.status()).toBe(503);
await expect(page.getByText("Offline", { exact: true })).toBeVisible();
const retry = page.getByRole("link", { name: "Reload" });
await expect(retry).toBeVisible();
const box = (await retry.boundingBox())!;
expect(box.width).toBeGreaterThanOrEqual(44);
expect(box.height).toBeGreaterThanOrEqual(44);
// Back online: the offline page reloads itself into the app.
const reloaded = page.waitForEvent("load");
await context.setOffline(false);
await reloaded;
await expect(page.locator("#main-content")).toBeVisible();
expect(new URL(page.url()).pathname).toBe("/timeline");
} finally {
await context.setOffline(false);
}
});
});
+12 -4
View File
@@ -110,7 +110,10 @@ export default defineConfig({
name: "chromium-desktop",
// The disk-layer spec needs a live service worker, which the shared
// `use` block blocks for every other spec. It runs in its own project.
testIgnore: ["v137-record-session-fence-offline.spec.ts"],
testIgnore: [
"v137-record-session-fence-offline.spec.ts",
"pwa-offline.spec.ts",
],
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
@@ -130,10 +133,14 @@ export default defineConfig({
// cannot pass is worse than no check, so the spec gets a project rather
// than a caveat.
//
// It uses no route mocks for exactly this reason, and it is the only
// spec in this project.
// It uses no route mocks for exactly this reason. v1.42 — the offline
// page journey (`pwa-offline.spec.ts`) joined it on the same terms:
// what the worker itself serves without a connection, no mocks.
name: "chromium-service-worker",
testMatch: ["v137-record-session-fence-offline.spec.ts"],
testMatch: [
"v137-record-session-fence-offline.spec.ts",
"pwa-offline.spec.ts",
],
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
@@ -220,6 +227,7 @@ export default defineConfig({
"doctor-report-delegate.spec.ts",
// Runs only in the service-worker project.
"v137-record-session-fence-offline.spec.ts",
"pwa-offline.spec.ts",
// The Apple Health import journey uploads an archive into the one
// shared account and its refusal control asserts that the account's
// Apple-Health row count did not move. A second project running the
+27 -7
View File
@@ -341,6 +341,14 @@ async function networkFirstApi(request) {
* scores straight into the HTML and emits `no-store`; the explicit
* path skip is belt-and-braces so a revoked share can never linger in
* CacheStorage and render back offline.
*
* In practice rule 1 covers every signed-in page: `src/proxy.ts` pins
* `private, no-store` on all of them, because several server-prefetch one
* account's record into the HTML. Only the public pages (sign-in, legal)
* land in this cache, so a navigation to an app page without a connection
* gets the offline page below, never somebody's cached record. A page that
* is already open keeps working offline from the cached chunks and the
* allowlisted data reads.
*/
function isCacheableNavigation(request, response) {
const cacheControl = response.headers.get("Cache-Control") || "";
@@ -568,16 +576,28 @@ async function networkFirst(event, cacheName) {
// "Offline" token + a generic retry hint expressed as an icon
// (the round-trip arrow), so the page does the right thing in any
// locale without shipping a translation bundle into the worker.
return new Response(
'<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>HealthLog — Offline</title><style>body{font-family:system-ui,sans-serif;background:#282a36;color:#f8f8f2;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}div{text-align:center;padding:2rem}h1{color:#bd93f9;margin:0 0 .5rem;font-size:2rem;letter-spacing:.05em}p{color:#6272a4;margin:.25rem 0;font-size:.9rem}svg{width:48px;height:48px;color:#6272a4;margin-bottom:1rem}</style></head><body><div><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/><path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/></svg><h1>HealthLog</h1><p>Offline</p></div></body></html>',
{
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
},
);
//
// v1.42 — the arrow is a real control now (a link to the same URL,
// 64 px), the page reloads by itself as soon as the connection is
// back, it follows the device's light or dark scheme, and it keeps
// clear of the notch and home indicator of an installed app.
return new Response(OFFLINE_PAGE, {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
}
const OFFLINE_PAGE =
'<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark light"><title>HealthLog — Offline</title><style>' +
":root{--bg:#282a36;--fg:#f8f8f2;--accent:#bd93f9;--muted:#a4a9c9;color-scheme:dark}" +
"@media (prefers-color-scheme:light){:root{--bg:#f3f2f5;--fg:#1f2030;--accent:#6b46c1;--muted:#5b5e78;color-scheme:light}}" +
"body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--fg);display:flex;align-items:center;justify-content:center;min-height:100vh;min-height:100dvh;margin:0;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);box-sizing:border-box}" +
"main{text-align:center;padding:2rem}h1{color:var(--accent);margin:0 0 .5rem;font-size:2rem;letter-spacing:.05em}p{color:var(--muted);margin:.25rem 0 1.5rem;font-size:1rem}" +
"a{display:inline-flex;align-items:center;justify-content:center;width:64px;height:64px;border-radius:9999px;color:var(--accent);border:2px solid currentColor}a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}svg{width:28px;height:28px}" +
'</style></head><body><main><h1>HealthLog</h1><p>Offline</p><a href="" aria-label="Reload" title="Reload"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/><path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/></svg></a></main>' +
'<script>addEventListener("online",function(){location.reload()})</script></body></html>';
/**
* Cache a successful network response without making CacheStorage
* availability part of the fetch contract. Quota, eviction, or trim failures
@@ -0,0 +1,108 @@
/**
* Structural guards for the installed app on a phone — the parts no browser
* in CI can show, because neither Chromium nor WebKit under Playwright
* reports a safe-area inset or opens an on-screen keyboard.
*
* 1. The authenticated shell takes the status-bar and side insets once,
* on its outer wrapper, and no 4rem header band pads itself inside its
* own height (which on a phone with a 59 px inset left the top bar a
* 5 px content box and pushed the logo across its bottom border).
* 2. The stylesheet carries the rules the shell and the keyboard bridge
* depend on.
* 3. Every form-field primitive keeps 16 px type on a touch screen: iOS
* Safari zooms the page into a focused field below 16 px, and a width
* breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways
* and to every iPad. The small size is allowed only behind
* `pointer-fine:`.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = resolve(__dirname, "../..");
const read = (path: string) => readFileSync(resolve(ROOT, path), "utf8");
/** Drop `//` and block comments so prose about a class cannot satisfy a check. */
function code(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
}
describe("the shell owns the safe area", () => {
it("the authenticated shell wrapper takes the insets", () => {
const shell = code(read("src/components/layout/auth-shell.tsx"));
expect(shell).toMatch(/className="shell-safe-area flex h-dvh flex-col"/);
});
it.each([
"src/components/layout/top-bar.tsx",
"src/components/insights/coach-panel/conversations-panel.tsx",
"src/components/day/day-layer.tsx",
])("%s pads no header band with the status-bar inset", (path) => {
const source = code(read(path));
expect(source).not.toMatch(/paddingTop:\s*"env\(safe-area-inset-top/);
expect(source).not.toMatch(/\bpt-\[env\(safe-area-inset-top/);
});
it("globals.css defines the shell band, the keyboard rules and pointer-fine", () => {
const css = read("src/app/globals.css");
expect(css).toMatch(
/\.shell-safe-area\s*\{[^}]*padding-top:\s*env\(safe-area-inset-top/,
);
expect(css).toMatch(
/\[data-sheet-side="bottom"\]\s*\{\s*bottom:\s*var\(--keyboard-inset/,
);
expect(css).toMatch(
/html\[data-keyboard="open"\] \[data-keyboard-hide\]\s*\{\s*display:\s*none/,
);
expect(css).toMatch(/@custom-variant pointer-fine/);
});
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=""',
);
expect(read("src/components/insights/layout-coach-fab.tsx")).toContain(
'data-keyboard-hide=""',
);
});
it("the sheet primitive names its side for those rules", () => {
expect(read("src/components/ui/sheet.tsx")).toMatch(
/data-sheet-side=\{side\}/,
);
});
});
describe("form fields keep 16 px on a touch screen", () => {
const FIELDS = [
"src/components/ui/input.tsx",
"src/components/ui/textarea.tsx",
"src/components/ui/native-select.tsx",
"src/components/ui/date-field.tsx",
"src/components/ui/time-field.tsx",
];
it.each(FIELDS)(
"%s sets text-base and shrinks only under a fine pointer",
(path) => {
// The field's own box is the class string that draws its border; the
// rest of the file (a picker's buttons, a caption) is not typed into.
const boxes = (code(read(path)).match(
/"[^"]*\bborder-input(?![\w-])[^"]*"/g,
) ?? []) as string[];
expect(boxes.length, `${path} has no field class`).toBeGreaterThan(0);
for (const box of boxes) {
expect(box).toMatch(/\btext-base\b/);
// Any sub-16 px size must sit behind `pointer-fine:`; `file:` styles
// the upload button, not typed text.
const bare = box.match(
/(?<![\w:-])(?:(?:sm|md|lg|xl):)?text-(?:xs|sm)(?![\w-])/g,
);
expect(bare ?? []).toEqual([]);
}
},
);
});
+21
View File
@@ -217,6 +217,10 @@ function bootServiceWorker(): SwHarness {
URL,
Promise,
console,
// Read through the context so a test can install fake timers before
// dispatching (the slow-navigation fallback arms one per navigation).
setTimeout: (...args: Parameters<typeof setTimeout>) => setTimeout(...args),
clearTimeout: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
};
vm.createContext(context);
vm.runInContext(SW_SOURCE, context);
@@ -617,6 +621,23 @@ describe("sw.js — networkFirst offline fallback", () => {
});
});
describe("sw.js — offline page", () => {
it("offers a retry control, reloads by itself when the connection returns, and follows the device scheme", async () => {
const harness = bootServiceWorker();
const res = await dispatchNavigationFetch(harness, "/");
expect(res.status).toBe(503);
const html = await res.text();
// A real control, not a decorative icon: a link back to the same URL.
expect(html).toMatch(/<a href="" aria-label="[^"]+"/);
expect(html).toContain('addEventListener("online"');
expect(html).toContain("prefers-color-scheme:light");
expect(html).toContain("viewport-fit=cover");
expect(html).toContain("env(safe-area-inset-bottom)");
// Still language-neutral: no sentence in any one locale.
expect(html).not.toMatch(/Keine Verbindung|You are offline/);
});
});
describe("sw.js — networkFirst privacy gate", () => {
it("does not cache a navigation response that carries Cache-Control: no-store", async () => {
const harness = bootServiceWorker();
@@ -1,5 +1,6 @@
/**
* Unit tests for the chunk-error auto-reload guard in `src/app/error.tsx`.
* Unit tests for the chunk-error auto-reload guard in
* `src/lib/pwa/chunk-reload.ts` (both error boundaries call it).
*
* The guard is keyed on the running shell's build version: one reload
* attempt per BROKEN SHELL, not one per session. After a successful
@@ -10,7 +11,11 @@
import { describe, expect, it } from "vitest";
import { chunkReloadGuardValue, shouldAttemptChunkReload } from "../error";
import {
chunkReloadGuardValue,
isChunkLoadError,
shouldAttemptChunkReload,
} from "@/lib/pwa/chunk-reload";
describe("shouldAttemptChunkReload", () => {
it("reloads when no attempt is recorded", () => {
@@ -39,3 +44,39 @@ describe("shouldAttemptChunkReload", () => {
expect(shouldAttemptChunkReload("unversioned", "")).toBe(false);
});
});
describe("isChunkLoadError", () => {
it.each([
[{ name: "ChunkLoadError", message: "x" }],
[{ name: "Error", message: "Loading chunk 123 failed." }],
[{ name: "Error", message: "Loading CSS chunk 7 failed." }],
[
{
name: "Error",
message: "Failed to load chunk /_next/static/chunks/a.js",
},
],
[
{
name: "TypeError",
message: "Failed to fetch dynamically imported module: /x.js",
},
],
// WebKit and Firefox word a failed dynamic import differently.
[{ name: "TypeError", message: "Importing a module script failed." }],
[
{
name: "TypeError",
message: "error loading dynamically imported module: /x.js",
},
],
])("recognises %j", (err) => {
expect(isChunkLoadError(err)).toBe(true);
});
it("leaves other errors to the error page", () => {
expect(
isChunkLoadError({ name: "TypeError", message: "x is undefined" }),
).toBe(false);
});
});
+4 -2
View File
@@ -328,8 +328,10 @@ export default function CoachConversationsPage() {
// mobile-only BottomNav band). L4 fix
// (`.planning/audits/2026-07-18-qa-ui.md`) — this had dropped the
// `safe-area-inset-top` term `/coach` subtracts, overshooting by
// ~50px on notched devices; restored to match exactly.
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
// ~50px on notched devices; restored to match exactly. v1.42 — the
// shell now takes that inset above the top bar at every width, so
// the desktop height subtracts it too, and `/coach/plans` matches.
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))]"
>
<CoachConversationsBody readOnly={!coach.available} />
</div>
+4 -2
View File
@@ -293,8 +293,10 @@ export default function CoachPlansPage() {
data-slot="coach-plans-page"
// Match `/coach/conversations`' full-bleed sizing: cancel the AuthShell
// padding and claim the viewport height below the top bar (minus the
// mobile-only BottomNav band).
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
// mobile-only BottomNav band). The status-bar inset of an installed
// app comes off as well, at every width: the shell takes it above
// the top bar (`shell-safe-area`).
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))]"
>
<CoachPlansBody readOnly={!coach.available} />
</div>
+5 -65
View File
@@ -2,47 +2,7 @@
import { useEffect } from "react";
import { ErrorDetails } from "@/components/error-details";
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
function isChunkLoadError(err: Error & { digest?: string }): boolean {
if (err.name === "ChunkLoadError") return true;
const msg = err.message ?? "";
return (
msg.includes("Loading chunk") ||
msg.includes("Loading CSS chunk") ||
msg.includes("Failed to load chunk") ||
msg.includes("Failed to fetch dynamically imported module")
);
}
/**
* Guard value recorded per auto-reload attempt — the running shell's
* build version, so the suppression is scoped to ONE shell. After a
* successful heal the reloaded page carries the NEW version and the
* guard re-arms by construction; only a shell that reloaded and is
* STILL broken (the new build is also missing the chunk) stays
* suppressed. Builds without an injected version degrade to the legacy
* once-per-session behaviour via the "unversioned" sentinel.
*/
export function chunkReloadGuardValue(shellVersion: string): string {
return shellVersion || "unversioned";
}
/**
* Pure reload decision so the guard semantics carry direct unit
* coverage. `stored` is the sessionStorage value from the last attempt
* (`null` when none happened); legacy guard values (the pre-v1.16.8
* timestamp) never match a version string, so a session stuck from
* before the change heals on its next chunk error.
*/
export function shouldAttemptChunkReload(
stored: string | null,
shellVersion: string,
): boolean {
return stored !== chunkReloadGuardValue(shellVersion);
}
import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload";
export default function AppError({
error,
@@ -60,30 +20,10 @@ export default function AppError({
g.__healthlog_onError?.(error);
}
// v1.4.38.3 — auto-recover from a stale-shell chunk-load error.
// After a deploy the cached shell still references old chunk
// filenames; they 404 and Next.js surfaces them as `ChunkLoadError`.
// A single hard reload fetches the new shell + chunk graph.
// The sessionStorage guard is keyed on the shell version (see
// `chunkReloadGuardValue`) so it suppresses a loop within ONE
// broken shell but re-arms after every deploy — the pre-v1.16.8
// once-per-session guard exhausted on multi-deploy days and left
// the user on the error page with no automatic way out.
if (typeof window !== "undefined" && isChunkLoadError(error)) {
try {
const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY);
if (shouldAttemptChunkReload(stored, SHELL_VERSION)) {
window.sessionStorage.setItem(
CHUNK_RELOAD_KEY,
chunkReloadGuardValue(SHELL_VERSION),
);
window.location.reload();
}
} catch {
// sessionStorage can throw under strict privacy modes — fall
// through to the error UI in that case.
}
}
// v1.4.38.3 — auto-recover from a stale-shell chunk-load error with a
// single reload (`src/lib/pwa/chunk-reload.ts`, shared with the root
// boundary).
reloadOnceForChunkError(error);
}, [error]);
return <ErrorDetails error={error} reset={reset} />;
+10
View File
@@ -1,5 +1,9 @@
"use client";
import { useEffect } from "react";
import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload";
/**
* Root-level error boundary. Renders when even the root layout itself fails,
* so it cannot rely on any providers (i18n, query, auth). Keep it static,
@@ -12,6 +16,12 @@ export default function GlobalError({
error: Error & { digest?: string };
reset: () => void;
}) {
// A chunk of the root layout's client tree missing after a deploy lands
// here rather than in `app/error.tsx`; it gets the same single reload.
useEffect(() => {
reloadOnceForChunkError(error);
}, [error]);
async function handleCopy() {
// Strip query string — OAuth callback routes carry sensitive tokens.
const loc = typeof window !== "undefined" ? window.location : null;
+77
View File
@@ -32,6 +32,20 @@
@custom-variant dark (&:is(.dark *));
/*
* `pointer-fine:` — a mouse or trackpad is the primary pointer. Form fields
* take the compact 14 px type only under it: iOS and iPadOS Safari zoom the
* page into any focused field whose text is smaller than 16 px, and a width
* breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways
* (844 px) and to every iPad. Touch-first screens keep the 16 px base at
* every width.
*/
@custom-variant pointer-fine {
@media (hover: hover) and (pointer: fine) {
@slot;
}
}
@theme inline {
/* Extra-small breakpoint (30rem / 480px) for the narrowest phones — the
default scale starts at sm (640px), which leaves a gap between an iPhone SE
@@ -511,6 +525,69 @@
}
}
/*
* `.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 `<html>` 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;
}
/*
* `.no-scrollbar` — keeps a container scrollable but hides the visible
* bar. Used by the mobile section strips in `<AdminShell>` and
+5 -2
View File
@@ -9,6 +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 { KeyMismatchPage } from "./key-mismatch-page";
const inter = Inter({
@@ -71,9 +72,11 @@ export const viewport: Viewport = {
// the active palette. The hex values are the resolved background of
// `--background` from `app/globals.css` for each theme so the bar
// edge never seams against the page on cold paint.
// The client rewrites both to the app's own theme once it is known
// (`applyThemeColor`), since that need not be the operating system's.
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f3f2f5" },
{ media: "(prefers-color-scheme: dark)", color: "#282a36" },
{ media: "(prefers-color-scheme: light)", color: THEME_COLOR.light },
{ media: "(prefers-color-scheme: dark)", color: THEME_COLOR.dark },
],
};
@@ -52,3 +52,95 @@ describe("resolveVersionPollDecision", () => {
).toBe("reload");
});
});
describe("evictAndReload", () => {
/**
* The Reload action used to unregister every service worker. Unregistering
* a registration ends its Web Push subscription, and nothing re-creates it,
* so each update silently switched push reminders off in the installed app.
* The action now updates the registration and deletes only HealthLog's
* caches.
*/
function stubBrowser(cacheNames: string[]) {
const calls = { update: 0, unregister: 0, reload: 0 };
const deleted: string[] = [];
const registration = {
update: async () => {
calls.update += 1;
},
unregister: async () => {
calls.unregister += 1;
return true;
},
};
const g = globalThis as Record<string, unknown>;
const saved = {
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
caches: g.caches,
window: g.window,
sessionStorage: g.sessionStorage,
};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
serviceWorker: { getRegistrations: async () => [registration] },
},
});
g.caches = {
keys: async () => cacheNames,
delete: async (name: string) => {
deleted.push(name);
return true;
},
};
g.window = {
location: {
reload: () => {
calls.reload += 1;
},
},
};
g.sessionStorage = { setItem: () => {}, getItem: () => null };
const restore = () => {
if (saved.navigator)
Object.defineProperty(globalThis, "navigator", saved.navigator);
g.caches = saved.caches;
g.window = saved.window;
g.sessionStorage = saved.sessionStorage;
};
return { calls, deleted, restore };
}
it("updates the worker instead of unregistering it, so the push subscription survives", async () => {
const { evictAndReload } = await import("../version-poller");
const browser = stubBrowser([]);
try {
await evictAndReload("9.9.9");
} finally {
browser.restore();
}
expect(browser.calls.unregister).toBe(0);
expect(browser.calls.update).toBe(1);
expect(browser.calls.reload).toBe(1);
});
it("deletes HealthLog's caches and leaves another app's on the same origin", async () => {
const { evictAndReload } = await import("../version-poller");
const browser = stubBrowser([
"healthlog-static-v1.41.2",
"healthlog-pages-v1.41.2",
"healthlog-data-v1.41.2",
"other-app-runtime",
]);
try {
await evictAndReload("9.9.9");
} finally {
browser.restore();
}
expect(browser.deleted.sort()).toEqual([
"healthlog-data-v1.41.2",
"healthlog-pages-v1.41.2",
"healthlog-static-v1.41.2",
]);
});
});
+10 -5
View File
@@ -249,10 +249,9 @@ function DayLayer() {
className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
>
{view(DockedTitle, {
headerClassName: cn(
SHELL_HEADER_BAND,
"border-border pt-[env(safe-area-inset-top,0px)]",
),
// No status-bar inset here: the docked column sits inside
// the app shell, which takes it (`shell-safe-area`).
headerClassName: cn(SHELL_HEADER_BAND, "border-border"),
})}
</aside>
</ShellSidePanel>
@@ -436,7 +435,13 @@ function DaySheet({
bottom
? cn(
"rounded-t-2xl transition-[height] duration-200 motion-reduce:transition-none",
full ? "h-[calc(100dvh-2.5rem)]" : "h-[58dvh]",
// Full height leaves a strip of the page above the
// sheet, and never less than the status bar of an
// installed app plus a margin: a 2.5rem strip put the
// handle under a 59 px Dynamic Island.
full
? "h-[calc(100dvh-max(2.5rem,env(safe-area-inset-top,0px)+0.75rem))]"
: "h-[58dvh]",
)
: "w-105 max-w-[90vw] sm:max-w-105",
)}
@@ -69,13 +69,22 @@ describe("<LayoutCoachFab>", () => {
// bottom-nav hides (`md:hidden`) — so the FAB never floats mid-air
// in the 768-1023px band. v1.18.1 (C5) — the corner inset is
// symmetric: right equals bottom at each breakpoint (`right-6`
// mobile-band / `md:right-8` desktop mirrors `md:bottom-8`).
expect(html).toContain("fixed right-6");
expect(html).toContain("md:right-8");
// mobile-band / `md:right-8` desktop mirrors `md:bottom-8`). v1.42 —
// each offset sits on top of the matching safe-area inset (the notch
// of a phone held sideways, an installed iPad's home indicator).
expect(html).toContain(
"fixed right-[calc(env(safe-area-inset-right,0px)+1.5rem)]",
);
expect(html).toContain(
"md:right-[calc(env(safe-area-inset-right,0px)+2rem)]",
);
expect(html).toContain(
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
);
expect(html).toContain("md:bottom-8");
expect(html).toContain(
"md:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)]",
);
expect(html).toContain('data-keyboard-hide=""');
expect(html).not.toContain("md:bottom-6");
expect(html).not.toContain("lg:bottom-6");
});
@@ -380,7 +380,7 @@ export function CoachInput({
autoCapitalize="sentences"
rows={1}
className={cn(
"order-1 w-full min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
"order-1 w-full min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:pointer-fine:text-sm",
"px-2 py-1.5",
"max-h-[9.5rem] overflow-auto",
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--primary)_35%,transparent)_transparent]",
@@ -209,12 +209,9 @@ export function ConversationsPanel({
const header = (inSheet: boolean) => (
<div
data-slot="coach-conversations-panel-header"
// Docked, the row reserves the safe-area inset the top bar reserves,
// so a standalone PWA on a tablet does not tuck it under the status
// bar; the band keeps its height (border-box), like the top bar.
style={
inSheet ? undefined : { paddingTop: "env(safe-area-inset-top, 0px)" }
}
// No inset of its own: docked, the panel is a column of the app
// shell, which already starts below the status bar of an installed
// app (`shell-safe-area` in `auth-shell.tsx`).
// Docked, the row is the top bar's band (height and bottom border
// from `SHELL_HEADER_BAND`), so the two borders draw one line.
className={cn(
+7 -2
View File
@@ -224,6 +224,8 @@ export function LayoutCoachFab() {
type="button"
size="icon"
data-slot="coach-fab"
// Steps aside while the on-screen keyboard is open (globals.css).
data-keyboard-hide=""
data-unread={showDot ? "true" : undefined}
onClick={handleOpen}
aria-label={accessibleLabel}
@@ -240,8 +242,11 @@ export function LayoutCoachFab() {
// 64 px bottom-nav plus the home-indicator safe-area inset
// (`bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]`), so the
// gap is asymmetric there by design, not drift.
"fixed right-6 z-40 size-14 rounded-full shadow-lg md:right-8",
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-8",
// v1.42 — the side and bottom insets apply at every width: a
// phone held sideways (844 px, past `md`) has its notch on one
// side, and an installed iPad its home indicator along the bottom.
"fixed right-[calc(env(safe-area-inset-right,0px)+1.5rem)] z-40 size-14 rounded-full shadow-lg md:right-[calc(env(safe-area-inset-right,0px)+2rem)]",
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)]",
// Dark glyph on the purple/pink gradient — white sat at
// ≈2.3:1 against the gradient midpoint; the background token
// reads ≈6.5:1.
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { useEffect } from "react";
import { trackKeyboardInset } from "@/lib/pwa/keyboard-inset";
/**
* Publishes the on-screen keyboard's height for the whole app, once (see
* `src/lib/pwa/keyboard-inset.ts` for the measurement and `globals.css` for
* what reads it: bottom sheets stand on the keyboard, the bottom bar and the
* Coach button step aside).
*/
export function KeyboardInsetBridge(): null {
useEffect(() => trackKeyboardInset(window, document.documentElement), []);
return null;
}
+23 -4
View File
@@ -278,13 +278,18 @@ export function AuthShell({
if (isStandalonePublicPage) {
return <>{children}</>;
}
// `min-h-dvh`, not `h-dvh`: a phone held sideways is ~390 px tall, and
// a sign-in form that is taller than a fixed-height box centres out of
// it at the top, where nothing can scroll back to it. A minimum lets the
// box grow and the document scroll. The safe-area padding keeps the form
// off the status bar and the home indicator in an installed app.
return (
<div className="flex h-dvh flex-col">
<div className="flex min-h-dvh flex-col pt-[env(safe-area-inset-top,0px)] pr-[env(safe-area-inset-right,0px)] pb-[env(safe-area-inset-bottom,0px)] pl-[env(safe-area-inset-left,0px)]">
{demoMode ? <DemoBanner /> : null}
<MaintainershipBanner />
<main
id="main-content"
className="flex flex-1 items-center justify-center px-4"
className="flex flex-1 items-center justify-center px-4 py-6"
>
{children}
</main>
@@ -352,7 +357,7 @@ export function AuthShell({
{showUnlockNotifier && user?.id ? (
<AchievementUnlockNotifier userId={user.id} />
) : null}
<div className="flex min-h-dvh flex-col">
<div className="flex min-h-dvh flex-col pt-[env(safe-area-inset-top,0px)] pr-[env(safe-area-inset-right,0px)] pl-[env(safe-area-inset-left,0px)]">
<MaintainershipBanner />
<div className="flex flex-1 items-center justify-center px-4 py-8">
{children}
@@ -418,7 +423,21 @@ export function AuthShell({
vertical scroller. Measured in `e2e/chrome-header-seam-banners.spec.ts`
at both breakpoints.
*/}
<div className="flex h-dvh flex-col">
{/*
The installed app draws under the status bar and beside the notch
(`viewport-fit=cover` + `black-translucent`), so the shell takes the
insets once, here, for everything inside it: the banner stack, the
top bar, the sidebar's logo band and any docked side panel all start
below the status bar together, and the seam between the top bar and
the logo band cannot come apart over an inset only one of them
reserved. The bands used to pad themselves inside their 4rem
(border-box), which on a phone with a 59 px inset left the top bar
five pixels of content box and pushed the logo and the account menu
across its bottom border. `shell-safe-area` paints the strip behind
the status bar in the chrome colour (globals.css). The bottom inset
stays with the bottom nav and `<main>`, which own that edge.
*/}
<div className="shell-safe-area flex h-dvh flex-col">
{/*
v1.4.43 QoL (M5) — `<OfflineBanner>` paints only when
`navigator.onLine === false`. Sits above the maintainership
+7 -1
View File
@@ -200,9 +200,15 @@ export function BottomNav() {
`bg-card` + `border-t` give the bar a clean visual edge so page
content that scrolls under the bar is occluded, not bleeding
through. */}
{/* The side insets keep the outer tabs clear of the notch on a phone
held sideways. `data-keyboard-hide`: while the on-screen keyboard
is open the bar steps aside (globals.css) instead of floating
over the field being typed into. */}
<nav
aria-label={t("nav.mobileNavigation")}
className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
data-slot="bottom-nav"
data-keyboard-hide=""
className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] backdrop-blur-md md:hidden"
>
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
{primaryLeft.map(renderPrimary)}
+3 -8
View File
@@ -92,14 +92,9 @@ export function TopBar() {
"bg-card/80 border-border sticky top-0 z-40 flex items-center justify-between px-4 backdrop-blur-md md:px-6",
SHELL_HEADER_BAND,
)}
// iOS PWA on notched iPhones overlays the status bar onto the
// sticky header unless we reserve the safe-area inset. The
// inline style adds `safe-area-inset-top` as padding-top on
// devices that report one and is a no-op on every other
// platform. The band stays 4rem tall either way (border-box), so
// on a device that reports an inset the content area inside it
// gives up those pixels.
style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
// The status-bar inset of the installed app is the shell's
// (`shell-safe-area` in `auth-shell.tsx`), not this band's: padded
// inside its own 4rem, a 59 px inset left the bar a 5 px content box.
>
{/* Mobile logo */}
<Link href="/" className="flex items-center gap-2 md:hidden">
+34 -1
View File
@@ -19,6 +19,8 @@ import { I18nProvider, useTranslations } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { applyThemeColor } from "@/lib/pwa/theme-color";
import { KeyboardInsetBridge } from "@/components/keyboard-inset-bridge";
import { VersionPoller } from "@/components/version-poller";
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
@@ -83,6 +85,9 @@ function getSystemTheme(): "dark" | "light" {
function applyTheme(resolved: "dark" | "light") {
document.documentElement.classList.remove("dark", "light");
document.documentElement.classList.add(resolved);
// The browser bar and an installed app's status bar follow the theme the
// page painted, not the operating system's scheme (see theme-color.ts).
applyThemeColor(document, resolved);
}
// ── Theme as an external store ───────────────────────
@@ -377,6 +382,33 @@ function OfflineMutationToaster() {
// ── Root Providers ───────────────────────────────────
/**
* The app-wide toaster.
*
* Its theme is the one the page painted, not `"system"`: the app's theme is
* its own setting (dark by default), and a phone in light mode showed light
* toasts over the dark app.
*
* Below 600 px Sonner lays toasts across the bottom edge, 16 px up, which on
* a phone is on top of the bottom bar: the "new version" hint sat over the
* tabs and the add button until it was dismissed. The mobile offset lifts
* them above the bar and the home indicator. From `md` the bar is gone and
* the desktop offset applies.
*/
function AppToaster() {
const { resolvedTheme } = useTheme();
return (
<Toaster
position="bottom-right"
richColors
theme={resolvedTheme}
mobileOffset={{
bottom: "calc(4rem + env(safe-area-inset-bottom, 0px) + 0.75rem)",
}}
/>
);
}
export function Providers({
children,
initialLocale,
@@ -404,7 +436,8 @@ export function Providers({
<SharedRecordGrantLossBridge />
<RecordSessionFenceBridge />
{children}
<Toaster position="bottom-right" richColors />
<AppToaster />
<KeyboardInsetBridge />
<VersionPoller />
<ServiceWorkerRegistrar />
</I18nProvider>
@@ -4,10 +4,12 @@ import { renderToStaticMarkup } from "react-dom/server";
import { Textarea } from "../textarea";
describe("<Textarea>", () => {
it("pins text-base on mobile and shrinks to text-sm on sm+ (iOS zoom defence)", () => {
it("pins text-base on touch screens and shrinks to text-sm only under a fine pointer (iOS zoom defence)", () => {
const html = renderToStaticMarkup(<Textarea id="note" />);
expect(html).toContain("text-base");
expect(html).toContain("sm:text-sm");
expect(html).toContain("sm:pointer-fine:text-sm");
// A width breakpoint alone handed 14 px to a phone held sideways.
expect(html).not.toMatch(/(^|\s)sm:text-sm(\s|")/);
});
it("floors at min-h-11 on mobile and min-h-9 on sm+ (WCAG 2.5.5 tap target)", () => {
@@ -62,7 +64,7 @@ describe("<Textarea>", () => {
expect(html).toContain("font-mono");
// iOS zoom defence still present after merge
expect(html).toContain("text-base");
expect(html).toContain("sm:text-sm");
expect(html).toContain("sm:pointer-fine:text-sm");
});
it("forwards rows / placeholder / maxLength to the underlying element", () => {
+6 -4
View File
@@ -60,11 +60,13 @@ const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
// `input-focus` tone, with no ring. The earlier `ring-2 ring-offset-2`
// painted a hard 4 px ring OUTSIDE the box — wider than the field's div and
// visibly mismatched next to the app's other inputs, and it shifted the
// highlight box on focus. `text-base md:text-sm` mirrors `<Input>` too: a
// sub-16 px input triggers iOS Safari's focus auto-zoom, which reads as a
// layout jump; 16 px below `md:` suppresses it.
// highlight box on focus. `text-base md:pointer-fine:text-sm` mirrors
// `<Input>` too: a sub-16 px input triggers iOS Safari's focus auto-zoom,
// which reads as a layout jump; 16 px on every touch screen suppresses it
// (`pointer-fine`, globals.css — a width breakpoint alone missed a phone
// held sideways and every iPad).
const FIELD_BASE_CLASSES =
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none";
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:pointer-fine:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none";
export interface DateFieldProps {
id?: string;
+1 -1
View File
@@ -76,7 +76,7 @@ function Input({
className={cn(
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5;
// shrink to 40 px on sm+ where pointer precision is higher.
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-11 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-11 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 md:pointer-fine:text-sm",
"focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
+4 -1
View File
@@ -56,8 +56,11 @@ import { cn } from "@/lib/utils";
// theme's `--muted-foreground` — same colour the shadcn `<SelectTrigger>`
// chevron uses.
// Type size: 16 px on a touch screen, 14 px only under a mouse or trackpad
// (`pointer-fine`, globals.css). iOS Safari zooms the page into a focused
// `<select>` whose text is smaller than 16 px, the same as into an input.
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-input-focus flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat";
"border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-input-focus flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-base pointer-fine:text-sm shadow-xs transition-[color,box-shadow] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat";
export type NativeSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
+8 -2
View File
@@ -250,8 +250,14 @@ export function ResponsiveSheet({
className={cn(
// Vertical-only scroll port — see the note on the Dialog
// branch's body below for why `overflow-y-auto` alone does not
// say that.
"flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto p-4",
// say that. `overscroll-contain`: a flick that reaches the end
// of the form stops there instead of chaining into the page
// (or the browser's pull-to-refresh) behind the sheet.
"flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto overscroll-contain p-4",
// Without a footer the body is the sheet's bottom edge, so it
// keeps its last row above the home indicator of an
// installed app; the footer does that when there is one.
!footer && "pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]",
bodyClassName,
)}
>
+6
View File
@@ -61,6 +61,12 @@ function SheetContent({
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
// Read by the safe-area and on-screen-keyboard rules in
// `globals.css`: a bottom sheet stands on the keyboard, the
// full-height sides keep clear of the status bar and the notch.
// Its own attribute rather than `data-slot`, which consumers
// replace with theirs (`responsive-sheet-content`, `day-panel`).
data-sheet-side={side}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
+3 -4
View File
@@ -12,10 +12,9 @@ import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
// `theme="system"` lets Sonner match the resolved theme on the
// root element (set pre-paint by the inline theme script in
// `app/layout.tsx`) instead of pinning dark — on light-mode the
// hardcoded dark toaster contrasted badly against the page.
// A fallback only: Sonner's `"system"` follows the operating
// system's scheme, not the app's theme, so the app's toaster passes
// its resolved theme (`AppToaster` in `providers.tsx`).
theme="system"
className="toaster group"
icons={{
+8 -5
View File
@@ -14,10 +14,13 @@ import { cn } from "@/lib/utils";
*
* What it bakes in:
*
* - `text-base sm:text-sm` — iOS Safari zooms the viewport on
* focus when the font-size renders below 16 px, which yanks the
* keyboard up and leaves the user lost. 16 px on mobile holds
* the viewport; 14 px on `sm`+ keeps the compact desktop look.
* - `text-base sm:pointer-fine:text-sm` — iOS Safari zooms the
* viewport on focus when the font-size renders below 16 px, which
* yanks the keyboard up and leaves the user lost. 16 px on a touch
* screen holds the viewport at any width (a phone held sideways is
* past `sm`, an iPad past `md`); 14 px only where a mouse or
* trackpad drives (`pointer-fine`, globals.css) keeps the compact
* desktop look.
*
* - `min-h-11 sm:min-h-9` — WCAG 2.5.5 / Apple HIG floor of 44 px
* on touch screens, shrinking to 36 px once we know we have a
@@ -65,7 +68,7 @@ function Textarea({
className={cn(
// iOS zoom defence + WCAG 2.5.5 tap-target floor; see the
// primitive's docblock for the full reasoning.
"border-input bg-background text-foreground placeholder:text-muted-foreground dark:bg-input/30 min-h-11 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-9 sm:text-sm",
"border-input bg-background text-foreground placeholder:text-muted-foreground dark:bg-input/30 min-h-11 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-9 sm:pointer-fine:text-sm",
"focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
+2 -2
View File
@@ -45,10 +45,10 @@ const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
// Focus vocabulary and font-size parity with `<Input>` / `<DateField>`: the
// border turns the neutral `input-focus` tone with no ring (not the old hard
// `ring-2 ring-offset-2`, which painted 4 px outside the div and jumped on
// focus) and `text-base md:text-sm`
// focus) and `text-base md:pointer-fine:text-sm`
// to keep iOS Safari from auto-zooming a sub-16 px input.
const FIELD_BASE_CLASSES =
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none";
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:pointer-fine:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none";
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
+24 -9
View File
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { HEALTHLOG_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy";
/**
* v1.4.38.4 — runtime self-heal for the stale-shell post-deploy
@@ -27,14 +28,24 @@ import { useTranslations } from "@/lib/i18n/context";
* Reload — an in-progress form or chat draft is never destroyed by a
* silent mid-session reload. On that click:
*
* 1. Unregister every active service worker so the next page load
* doesn't reinstall the old SW.
* 2. Delete every CacheStorage entry so the precached root HTML
* and the `/_next/static/*` chunks from the previous deploy
* can't be served back.
* 1. Ask the service worker registration to update, so the new
* deploy's worker (its `sw-version.js` changed) installs and takes
* over; it activates at once (`skipWaiting` + `clients.claim`) and
* drops the previous release's caches itself.
* 2. Delete HealthLog's CacheStorage entries so the `/_next/static/*`
* chunks and page shells from the previous deploy can't be served
* back while that happens.
* 3. `window.location.reload()` — fetches the fresh shell + new
* chunk graph.
*
* v1.42 — the worker is updated, never unregistered. Unregistering a
* registration ends its Web Push subscription, and nothing re-creates one:
* every click on Reload silently switched off push reminders in the
* installed app (the server kept the dead endpoint, the settings card
* showed push as off). An update keeps the registration and with it the
* subscription. The page navigation is network-first in the worker, so the
* reload reaches the new shell whichever worker answers it.
*
* The toast is shown once per detected version (the poll keeps
* running, but a repeated mismatch against a version we already
* surfaced doesn't re-spam every 60 s). The reload guard is keyed on
@@ -90,7 +101,7 @@ async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
// to break the loop within one document lifetime.
let inMemoryReloadGuard: string | null = null;
async function evictAndReload(targetVersion: string): Promise<void> {
export async function evictAndReload(targetVersion: string): Promise<void> {
inMemoryReloadGuard = targetVersion;
try {
sessionStorage.setItem(SESSION_GUARD_KEY, targetVersion);
@@ -102,16 +113,20 @@ async function evictAndReload(targetVersion: string): Promise<void> {
if ("serviceWorker" in navigator) {
try {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
await Promise.all(regs.map((r) => r.update()));
} catch {
/* best effort */
/* best effort — the reload below still fetches the new shell */
}
}
if (typeof caches !== "undefined") {
try {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
await Promise.all(
keys
.filter((k) => HEALTHLOG_CACHE_NAME_RE.test(k))
.map((k) => caches.delete(k)),
);
} catch {
/* best effort */
}
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import {
KEYBOARD_MIN_PX,
keyboardInset,
trackKeyboardInset,
} from "@/lib/pwa/keyboard-inset";
describe("keyboardInset", () => {
it("is the layout viewport left below the visual one", () => {
// iPhone 15, portrait: 844 tall, keyboard 336.
expect(keyboardInset(844, { height: 508, offsetTop: 0, scale: 1 })).toBe(
336,
);
});
it("counts a visual viewport iOS panned to keep the field in view", () => {
expect(keyboardInset(844, { height: 508, offsetTop: 120, scale: 1 })).toBe(
216,
);
});
it("is zero without a keyboard", () => {
expect(keyboardInset(844, { height: 844, offsetTop: 0, scale: 1 })).toBe(0);
});
it("does not read a collapsing toolbar as a keyboard", () => {
expect(
keyboardInset(844, {
height: 844 - (KEYBOARD_MIN_PX - 1),
offsetTop: 0,
scale: 1,
}),
).toBe(0);
});
it("does not read pinch-zoom as a keyboard", () => {
expect(keyboardInset(844, { height: 422, offsetTop: 0, scale: 2 })).toBe(0);
});
it("is zero where the browser has no visual viewport", () => {
expect(keyboardInset(844, null)).toBe(0);
});
});
class FakeViewport extends EventTarget {
height = 844;
offsetTop = 0;
scale = 1;
}
function fakeRoot() {
const props = new Map<string, string>();
const attrs = new Map<string, string>();
return {
props,
attrs,
el: {
style: {
setProperty: (k: string, v: string) => props.set(k, v),
removeProperty: (k: string) => props.delete(k),
},
setAttribute: (k: string, v: string) => attrs.set(k, v),
removeAttribute: (k: string) => attrs.delete(k),
} as unknown as HTMLElement,
};
}
describe("trackKeyboardInset", () => {
it("publishes the inset while the keyboard is open and clears it after", () => {
const viewport = new FakeViewport();
const root = fakeRoot();
const stop = trackKeyboardInset(
{
innerHeight: 844,
visualViewport: viewport as unknown as VisualViewport,
},
root.el,
);
expect(root.attrs.has("data-keyboard")).toBe(false);
viewport.height = 508;
viewport.dispatchEvent(new Event("resize"));
expect(root.props.get("--keyboard-inset")).toBe("336px");
expect(root.attrs.get("data-keyboard")).toBe("open");
viewport.offsetTop = 100;
viewport.dispatchEvent(new Event("scroll"));
expect(root.props.get("--keyboard-inset")).toBe("236px");
viewport.height = 844;
viewport.offsetTop = 0;
viewport.dispatchEvent(new Event("resize"));
expect(root.props.has("--keyboard-inset")).toBe(false);
expect(root.attrs.has("data-keyboard")).toBe(false);
viewport.height = 508;
stop();
viewport.dispatchEvent(new Event("resize"));
expect(root.attrs.has("data-keyboard")).toBe(false);
});
it("does nothing without a visual viewport", () => {
const root = fakeRoot();
const stop = trackKeyboardInset(
{ innerHeight: 844, visualViewport: null },
root.el,
);
stop();
expect(root.attrs.size).toBe(0);
expect(root.props.size).toBe(0);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { applyThemeColor, THEME_COLOR } from "@/lib/pwa/theme-color";
function fakeMeta(content: string) {
const attrs = new Map<string, string>([["content", content]]);
return {
attrs,
setAttribute: (k: string, v: string) => attrs.set(k, v),
};
}
describe("applyThemeColor", () => {
it("rewrites both media-keyed tags to the theme the page painted", () => {
// A phone in light mode, the app in its dark default: the light tag is
// the one the browser applies, and it must carry the dark colour.
const light = fakeMeta(THEME_COLOR.light);
const dark = fakeMeta(THEME_COLOR.dark);
const doc = {
querySelectorAll: (selector: string) => {
expect(selector).toBe('meta[name="theme-color"]');
return [light, dark];
},
} as unknown as Document;
applyThemeColor(doc, "dark");
expect(light.attrs.get("content")).toBe(THEME_COLOR.dark);
expect(dark.attrs.get("content")).toBe(THEME_COLOR.dark);
applyThemeColor(doc, "light");
expect(light.attrs.get("content")).toBe(THEME_COLOR.light);
expect(dark.attrs.get("content")).toBe(THEME_COLOR.light);
});
it("matches each theme's --background in globals.css", () => {
const css = readFileSync(
resolve(__dirname, "../../../app/globals.css"),
"utf8",
);
// The dark palette is declared in hex; the bar colour must be it exactly.
expect(css).toMatch(
new RegExp(`--background:\\s*${THEME_COLOR.dark};`, "i"),
);
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* One reload after a stale-shell chunk failure.
*
* After a deploy, a page that was open across it still references the
* previous build's chunk files; they 404 and the lazy load fails. A client
* navigation already recovers on its own (Next turns a failed chunk during a
* route change into a full page load), but a chunk that fails inside a page
* (a `next/dynamic` panel, a lazily imported sheet) surfaces in an error
* boundary instead. Both boundaries, the route one (`app/error.tsx`) and the
* root one (`app/global-error.tsx`), call `reloadOnceForChunkError`, which
* loads the page once so it arrives with the new build's chunk graph.
*
* The guard is keyed on the running shell's version: it suppresses a loop
* within ONE broken shell, and re-arms after every deploy (the pre-v1.16.8
* once-per-session key exhausted itself on multi-deploy days and stranded the
* user on the error page).
*/
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
/**
* A failed chunk, in the words of each loader and engine: webpack's
* `ChunkLoadError` / "Loading chunk", its CSS twin, Turbopack's "Failed to
* load chunk", and a native dynamic import as Chromium, WebKit and Firefox
* report it.
*/
export function isChunkLoadError(err: { name?: string; message?: string }) {
if (err.name === "ChunkLoadError") return true;
const msg = err.message ?? "";
return (
msg.includes("Loading chunk") ||
msg.includes("Loading CSS chunk") ||
msg.includes("Failed to load chunk") ||
msg.includes("Failed to fetch dynamically imported module") ||
msg.includes("Importing a module script failed") ||
msg.includes("error loading dynamically imported module")
);
}
/**
* Guard value recorded per auto-reload attempt — the running shell's build
* version, so the suppression is scoped to ONE shell. After a successful
* heal the reloaded page carries the NEW version and the guard re-arms by
* construction; only a shell that reloaded and is STILL broken (the new
* build is also missing the chunk) stays suppressed. Builds without an
* injected version degrade to once-per-session via the "unversioned"
* sentinel.
*/
export function chunkReloadGuardValue(shellVersion: string): string {
return shellVersion || "unversioned";
}
/**
* Pure reload decision. `stored` is the sessionStorage value from the last
* attempt (`null` when none happened); legacy guard values (the pre-v1.16.8
* timestamp) never match a version string, so a session stuck from before
* the change heals on its next chunk error.
*/
export function shouldAttemptChunkReload(
stored: string | null,
shellVersion: string,
): boolean {
return stored !== chunkReloadGuardValue(shellVersion);
}
/**
* Reload once if `error` is a chunk failure and this shell has not tried
* yet. Returns whether a reload was started.
*/
export function reloadOnceForChunkError(
error: { name?: string; message?: string },
shellVersion: string = process.env.NEXT_PUBLIC_APP_VERSION || "",
): boolean {
if (typeof window === "undefined" || !isChunkLoadError(error)) return false;
try {
const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY);
if (!shouldAttemptChunkReload(stored, shellVersion)) return false;
window.sessionStorage.setItem(
CHUNK_RELOAD_KEY,
chunkReloadGuardValue(shellVersion),
);
} catch {
// sessionStorage can throw under strict privacy modes; without a guard
// a reload could loop, so fall through to the error UI instead.
return false;
}
window.location.reload();
return true;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* How much of the layout viewport the on-screen keyboard covers.
*
* iOS Safari, and Chrome on Android by default (`interactive-widget` left at
* `resizes-visual`), do not shrink the layout viewport when the keyboard
* opens; only the visual viewport shrinks. Everything `position: fixed` stays
* anchored to the layout viewport, so a bottom sheet keeps its footer (Save,
* the Coach composer) behind the keyboard. The covered height is what is left
* of the layout viewport below the visual one:
*
* innerHeight - (visualViewport.offsetTop + visualViewport.height)
*
* Two things shrink the visual viewport without a keyboard, and neither may
* read as one: pinch-zoom (the scale moves off 1) and the few dozen pixels a
* collapsing toolbar can account for. Hence the scale check and the floor.
*
* Published as the `--keyboard-inset` custom property and `data-keyboard` on
* `<html>`; the rules that use them live in `globals.css`.
*/
/** Below this, the difference is browser chrome, not a keyboard. */
export const KEYBOARD_MIN_PX = 120;
export interface VisualViewportLike {
height: number;
offsetTop: number;
scale: number;
}
export function keyboardInset(
innerHeight: number,
viewport: VisualViewportLike | null | undefined,
): number {
if (!viewport) return 0;
if (Math.abs(viewport.scale - 1) > 0.01) return 0;
const covered = innerHeight - viewport.offsetTop - viewport.height;
return covered >= KEYBOARD_MIN_PX ? Math.round(covered) : 0;
}
export const KEYBOARD_INSET_PROPERTY = "--keyboard-inset";
export const KEYBOARD_ATTRIBUTE = "data-keyboard";
/**
* Track the keyboard on `win` and publish it on `root`. Returns the cleanup.
* A browser without `visualViewport` gets nothing published, which leaves
* every rule at its keyboard-closed default.
*/
export function trackKeyboardInset(
win: Pick<Window, "innerHeight" | "visualViewport">,
root: HTMLElement,
): () => void {
const viewport = win.visualViewport;
if (!viewport) return () => {};
let current = -1;
const update = () => {
const inset = keyboardInset(win.innerHeight, viewport);
if (inset === current) return;
current = inset;
if (inset > 0) {
root.style.setProperty(KEYBOARD_INSET_PROPERTY, `${inset}px`);
root.setAttribute(KEYBOARD_ATTRIBUTE, "open");
} else {
root.style.removeProperty(KEYBOARD_INSET_PROPERTY);
root.removeAttribute(KEYBOARD_ATTRIBUTE);
}
};
update();
viewport.addEventListener("resize", update);
// iOS pans the visual viewport to keep the focused field in view, which
// moves `offsetTop` without a resize.
viewport.addEventListener("scroll", update);
return () => {
viewport.removeEventListener("resize", update);
viewport.removeEventListener("scroll", update);
root.style.removeProperty(KEYBOARD_INSET_PROPERTY);
root.removeAttribute(KEYBOARD_ATTRIBUTE);
};
}
+4 -4
View File
@@ -35,16 +35,16 @@ import {
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import { queryKeys } from "@/lib/query-keys";
import { isReadingSharedRecord } from "@/lib/query-keys/record-scope";
// Session-end cleanup removes every current/legacy HealthLog cache family
// while leaving unrelated CacheStorage namespaces on the same origin
// untouched; the name contract is shared with `public/sw.js`.
import { HEALTHLOG_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy";
const DB_NAME = "healthlog-query-cache";
const STORE = "kv";
const KEY = "react-query";
const VERSION_KEY = "react-query-version";
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // discard anything older than a day
// Keep this ownership contract aligned with `public/sw.js`. Session-end
// cleanup removes every current/legacy HealthLog cache family while leaving
// unrelated CacheStorage namespaces on the same origin untouched.
const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/;
/**
* Upper bound on the serialized snapshot. The allowlist already caps WHAT is
+9
View File
@@ -14,6 +14,15 @@
* allowlisted by mistake — mirroring the idempotency cache's refusal.
*/
/**
* The worker's own cache names: `healthlog-{static,pages,data}-<version>`.
* Everything the app deletes from CacheStorage (session end, the "new
* version" reload) matches against this and nothing else, so another app's
* caches on the same origin are left alone. Keep in lockstep with
* `HEALTHLOG_CACHE_NAME_RE` in `public/sw.js`.
*/
export const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/;
/**
* Path prefixes whose safe GET reads are eligible for the offline data cache.
*
+30
View File
@@ -0,0 +1,30 @@
/**
* The browser-chrome colour of each theme: the Android address bar, the
* installed app's title bar, and the status bar of an installed iOS app.
*
* Each value is the resolved `--background` of its theme in
* `app/globals.css`, so the bar never seams against the page on a cold paint.
* The root layout declares both as `<meta name="theme-color" media=…>` keyed on
* the operating system's scheme, which is all a server render can know.
*
* The app's own theme is not the operating system's, though: with nothing
* stored it defaults to dark, and a person can pick light or dark by hand. A
* phone in light mode running HealthLog in dark mode then showed a light bar
* over a dark page. `applyThemeColor` rewrites both tags to the theme the page
* actually painted, whichever scheme the media query would pick.
*/
export const THEME_COLOR = {
light: "#f3f2f5",
dark: "#282a36",
} as const;
export function applyThemeColor(
doc: Pick<Document, "querySelectorAll">,
resolved: keyof typeof THEME_COLOR,
): void {
for (const meta of doc.querySelectorAll<HTMLMetaElement>(
'meta[name="theme-color"]',
)) {
meta.setAttribute("content", THEME_COLOR[resolved]);
}
}