mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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
@@ -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 */
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user