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

@@ -1,5 +1,6 @@
/**
* Unit tests for the chunk-error auto-reload guard in `src/app/error.tsx`.
* Unit tests for the chunk-error auto-reload guard in
* `src/lib/pwa/chunk-reload.ts` (both error boundaries call it).
*
* The guard is keyed on the running shell's build version: one reload
* attempt per BROKEN SHELL, not one per session. After a successful
@@ -10,7 +11,11 @@
import { describe, expect, it } from "vitest";
import { chunkReloadGuardValue, shouldAttemptChunkReload } from "../error";
import {
chunkReloadGuardValue,
isChunkLoadError,
shouldAttemptChunkReload,
} from "@/lib/pwa/chunk-reload";
describe("shouldAttemptChunkReload", () => {
it("reloads when no attempt is recorded", () => {
@@ -39,3 +44,39 @@ describe("shouldAttemptChunkReload", () => {
expect(shouldAttemptChunkReload("unversioned", "")).toBe(false);
});
});
describe("isChunkLoadError", () => {
it.each([
[{ name: "ChunkLoadError", message: "x" }],
[{ name: "Error", message: "Loading chunk 123 failed." }],
[{ name: "Error", message: "Loading CSS chunk 7 failed." }],
[
{
name: "Error",
message: "Failed to load chunk /_next/static/chunks/a.js",
},
],
[
{
name: "TypeError",
message: "Failed to fetch dynamically imported module: /x.js",
},
],
// WebKit and Firefox word a failed dynamic import differently.
[{ name: "TypeError", message: "Importing a module script failed." }],
[
{
name: "TypeError",
message: "error loading dynamically imported module: /x.js",
},
],
])("recognises %j", (err) => {
expect(isChunkLoadError(err)).toBe(true);
});
it("leaves other errors to the error page", () => {
expect(
isChunkLoadError({ name: "TypeError", message: "x is undefined" }),
).toBe(false);
});
});
+4 -2
View File
@@ -328,8 +328,10 @@ export default function CoachConversationsPage() {
// mobile-only BottomNav band). L4 fix
// (`.planning/audits/2026-07-18-qa-ui.md`) — this had dropped the
// `safe-area-inset-top` term `/coach` subtracts, overshooting by
// ~50px on notched devices; restored to match exactly.
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
// ~50px on notched devices; restored to match exactly. v1.42 — the
// shell now takes that inset above the top bar at every width, so
// the desktop height subtracts it too, and `/coach/plans` matches.
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))]"
>
<CoachConversationsBody readOnly={!coach.available} />
</div>
+4 -2
View File
@@ -293,8 +293,10 @@ export default function CoachPlansPage() {
data-slot="coach-plans-page"
// Match `/coach/conversations`' full-bleed sizing: cancel the AuthShell
// padding and claim the viewport height below the top bar (minus the
// mobile-only BottomNav band).
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
// mobile-only BottomNav band). The status-bar inset of an installed
// app comes off as well, at every width: the shell takes it above
// the top bar (`shell-safe-area`).
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))]"
>
<CoachPlansBody readOnly={!coach.available} />
</div>
+5 -65
View File
@@ -2,47 +2,7 @@
import { useEffect } from "react";
import { ErrorDetails } from "@/components/error-details";
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
function isChunkLoadError(err: Error & { digest?: string }): boolean {
if (err.name === "ChunkLoadError") return true;
const msg = err.message ?? "";
return (
msg.includes("Loading chunk") ||
msg.includes("Loading CSS chunk") ||
msg.includes("Failed to load chunk") ||
msg.includes("Failed to fetch dynamically imported module")
);
}
/**
* Guard value recorded per auto-reload attempt — the running shell's
* build version, so the suppression is scoped to ONE shell. After a
* successful heal the reloaded page carries the NEW version and the
* guard re-arms by construction; only a shell that reloaded and is
* STILL broken (the new build is also missing the chunk) stays
* suppressed. Builds without an injected version degrade to the legacy
* once-per-session behaviour via the "unversioned" sentinel.
*/
export function chunkReloadGuardValue(shellVersion: string): string {
return shellVersion || "unversioned";
}
/**
* Pure reload decision so the guard semantics carry direct unit
* coverage. `stored` is the sessionStorage value from the last attempt
* (`null` when none happened); legacy guard values (the pre-v1.16.8
* timestamp) never match a version string, so a session stuck from
* before the change heals on its next chunk error.
*/
export function shouldAttemptChunkReload(
stored: string | null,
shellVersion: string,
): boolean {
return stored !== chunkReloadGuardValue(shellVersion);
}
import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload";
export default function AppError({
error,
@@ -60,30 +20,10 @@ export default function AppError({
g.__healthlog_onError?.(error);
}
// v1.4.38.3 — auto-recover from a stale-shell chunk-load error.
// After a deploy the cached shell still references old chunk
// filenames; they 404 and Next.js surfaces them as `ChunkLoadError`.
// A single hard reload fetches the new shell + chunk graph.
// The sessionStorage guard is keyed on the shell version (see
// `chunkReloadGuardValue`) so it suppresses a loop within ONE
// broken shell but re-arms after every deploy — the pre-v1.16.8
// once-per-session guard exhausted on multi-deploy days and left
// the user on the error page with no automatic way out.
if (typeof window !== "undefined" && isChunkLoadError(error)) {
try {
const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY);
if (shouldAttemptChunkReload(stored, SHELL_VERSION)) {
window.sessionStorage.setItem(
CHUNK_RELOAD_KEY,
chunkReloadGuardValue(SHELL_VERSION),
);
window.location.reload();
}
} catch {
// sessionStorage can throw under strict privacy modes — fall
// through to the error UI in that case.
}
}
// v1.4.38.3 — auto-recover from a stale-shell chunk-load error with a
// single reload (`src/lib/pwa/chunk-reload.ts`, shared with the root
// boundary).
reloadOnceForChunkError(error);
}, [error]);
return <ErrorDetails error={error} reset={reset} />;
+10
View File
@@ -1,5 +1,9 @@
"use client";
import { useEffect } from "react";
import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload";
/**
* Root-level error boundary. Renders when even the root layout itself fails,
* so it cannot rely on any providers (i18n, query, auth). Keep it static,
@@ -12,6 +16,12 @@ export default function GlobalError({
error: Error & { digest?: string };
reset: () => void;
}) {
// A chunk of the root layout's client tree missing after a deploy lands
// here rather than in `app/error.tsx`; it gets the same single reload.
useEffect(() => {
reloadOnceForChunkError(error);
}, [error]);
async function handleCopy() {
// Strip query string — OAuth callback routes carry sensitive tokens.
const loc = typeof window !== "undefined" ? window.location : null;
+77
View File
@@ -32,6 +32,20 @@
@custom-variant dark (&:is(.dark *));
/*
* `pointer-fine:` — a mouse or trackpad is the primary pointer. Form fields
* take the compact 14 px type only under it: iOS and iPadOS Safari zoom the
* page into any focused field whose text is smaller than 16 px, and a width
* breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways
* (844 px) and to every iPad. Touch-first screens keep the 16 px base at
* every width.
*/
@custom-variant pointer-fine {
@media (hover: hover) and (pointer: fine) {
@slot;
}
}
@theme inline {
/* Extra-small breakpoint (30rem / 480px) for the narrowest phones — the
default scale starts at sm (640px), which leaves a gap between an iPhone SE
@@ -511,6 +525,69 @@
}
}
/*
* `.shell-safe-area` — the authenticated app shell's share of the installed
* app's safe area (see the note in `auth-shell.tsx`). The shell takes the top
* and side insets as padding, and the strip behind the status bar is painted
* in the chrome colour (`--card`, the top bar and the sidebar) rather than
* the page colour, so the status bar reads as part of the bar beneath it. A
* zero inset makes the gradient zero tall: nothing changes in a browser tab.
*/
.shell-safe-area {
padding-top: env(safe-area-inset-top, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-left: env(safe-area-inset-left, 0px);
background: linear-gradient(var(--card), var(--card)) top / 100%
env(safe-area-inset-top, 0px) no-repeat;
}
/*
* The on-screen keyboard. iOS Safari, and Chrome on Android by default,
* shrink only the visual viewport when the keyboard opens: everything
* `position: fixed` stays anchored to the layout viewport, so a bottom sheet
* keeps its footer (Save, the Coach composer) behind the keyboard and the
* bottom bar floats somewhere under it. `useKeyboardInset()` (mounted once
* in the providers) publishes the covered height as `--keyboard-inset` and
* sets `data-keyboard` on `<html>` while it is open.
*
* Bottom sheets then stand on the keyboard and give up the height it takes,
* so their own scroll body (every bottom sheet is a flex column with a
* scrolling middle) carries the form, and the bottom bar and the Coach
* button step aside until the keyboard is gone.
*/
[data-sheet-side="bottom"] {
bottom: var(--keyboard-inset, 0px);
}
html[data-keyboard="open"] [data-sheet-side="bottom"] {
max-height: calc(
100dvh - var(--keyboard-inset, 0px) - env(safe-area-inset-top, 0px) - 0.5rem
) !important;
}
html[data-keyboard="open"] [data-keyboard-hide] {
display: none !important;
}
/*
* Side and top sheets run the full height of the window. In an installed
* app on a tablet the status bar sits over their first 24 px, and a phone
* held sideways has its notch over one side, so the panel keeps its header
* clear of both. A transparent border rather than padding: every consumer
* sets its own padding (often `p-0`), which would override a padding here,
* while the border adds the clearance under any of them and lets the
* panel's own background show through it.
*/
[data-sheet-side="right"],
[data-sheet-side="left"],
[data-sheet-side="top"] {
border-top: env(safe-area-inset-top, 0px) solid transparent;
}
[data-sheet-side="right"] {
border-right: env(safe-area-inset-right, 0px) solid transparent;
}
[data-sheet-side="left"] {
border-left: env(safe-area-inset-left, 0px) solid transparent;
}
/*
* `.no-scrollbar` — keeps a container scrollable but hides the visible
* bar. Used by the mobile section strips in `<AdminShell>` and
+5 -2
View File
@@ -9,6 +9,7 @@ import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
import { THEME_COLOR } from "@/lib/pwa/theme-color";
import { KeyMismatchPage } from "./key-mismatch-page";
const inter = Inter({
@@ -71,9 +72,11 @@ export const viewport: Viewport = {
// the active palette. The hex values are the resolved background of
// `--background` from `app/globals.css` for each theme so the bar
// edge never seams against the page on cold paint.
// The client rewrites both to the app's own theme once it is known
// (`applyThemeColor`), since that need not be the operating system's.
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f3f2f5" },
{ media: "(prefers-color-scheme: dark)", color: "#282a36" },
{ media: "(prefers-color-scheme: light)", color: THEME_COLOR.light },
{ media: "(prefers-color-scheme: dark)", color: THEME_COLOR.dark },
],
};