mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge remote-tracking branch 'origin/release/v1.42.0' into v142/ui-audit
This commit is contained in:
commit
492c2a4131
41 files changed
+1268
-151
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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([]);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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} />;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)$/;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user