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

@@ -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 */
}