From 468e38c3ef1e773f04ebe5ddac5a7ebbd5a8ec54 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 21 May 2026 23:06:13 +0200
Subject: [PATCH] fix(reconcile): close W10 QA Mediums and Lows
Bundle of small reconcile fixes from the W10 QA round:
- Switch tap-target padding -12 px -> -13 px closes the 1.6 px vertical short-fall to spec-strict WCAG 2.5.5 (design M1).
- animate-pulse skeleton sites in daily-briefing, trend-annotation and doctor-report dialog now carry motion-reduce:animate-none (design M2).
- Doctor-report unavailable rows render with text-muted-foreground instead of line-through so the visual reads as 'unavailable' rather than 'deleted'; the italic hint line below still carries the semantic (design M4).
- WelcomeCarousel scrollIntoView now reads scrollBehaviorForUser from src/lib/motion so all four advertised call-sites share the helper (simplifier L-1).
- Coach replay-injection audit row renamed audit.coach.replay-injection -> insights.coach.replay_injection to match the . convention across the audit ledger (simplifier L-2).
- /api/integrations/withings/resume drops the unused request param (the handler reads neither body nor headers; requireAuth scopes the call) (simplifier M-4).
- verifyRegistration body gets the same Zod boundary narrow W13 L-3 added to verifyAuthentication; SimpleWebAuthn still owns cryptographic validation (senior-dev L-1).
---
src/app/api/insights/chat/route.ts | 8 ++--
.../api/integrations/withings/resume/route.ts | 5 +--
.../doctor-report-section-toggles.test.tsx | 7 +++-
.../doctor-report/doctor-report-dialog.tsx | 6 +--
src/components/insights/daily-briefing.tsx | 10 ++---
src/components/insights/trend-annotation.tsx | 6 +--
src/components/onboarding/WelcomeCarousel.tsx | 6 +--
src/components/ui/switch.tsx | 8 ++--
src/lib/auth/passkey.ts | 41 ++++++++++++++++---
tests/integration/coach-chat.test.ts | 2 +-
10 files changed, 66 insertions(+), 33 deletions(-)
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index e3e6650f8..76a7f59ce 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -201,10 +201,12 @@ async function handleChatRequest(request: NextRequest): Promise {
// slipped past the regex bank on a previous turn would re-enter
// the prompt every reply. Re-run the detector against every
// user-turn re-loaded from DB; on a hit, short-circuit the SSE
- // with a refusal AND drop an `audit.coach.replay-injection`
+ // with a refusal AND drop an `insights.coach.replay_injection`
// row so the failure case is observable. The audit row carries
// the conversation id (server-owned), the turn index (no PII)
- // and the matched reason — never the message content.
+ // and the matched reason — never the message content. v1.4.43
+ // W10 simplifier-L-2 — action name follows the `.`
+ // convention (no `audit.` prefix; no `.replay-injection` dash).
for (let i = 0; i < priorTurns.length; i++) {
const turn = priorTurns[i];
if (turn.role !== "user") continue;
@@ -214,7 +216,7 @@ async function handleChatRequest(request: NextRequest): Promise {
action: { name: "insights.coach.replay_injection" },
meta: { reason: replayed.reason, turnIndex: i },
});
- await auditLog("audit.coach.replay-injection", {
+ await auditLog("insights.coach.replay_injection", {
userId,
details: {
conversationId: existing.id,
diff --git a/src/app/api/integrations/withings/resume/route.ts b/src/app/api/integrations/withings/resume/route.ts
index ca6ab21e0..0065add4b 100644
--- a/src/app/api/integrations/withings/resume/route.ts
+++ b/src/app/api/integrations/withings/resume/route.ts
@@ -22,8 +22,6 @@
* idempotent — a second click in the same second sees the same
* post-state and writes no extra audit row.
*/
-import type { NextRequest } from "next/server";
-
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -32,8 +30,7 @@ import { resumeIntegrationFromPark } from "@/lib/integrations/status";
export const dynamic = "force-dynamic";
-export const POST = apiHandler(async (request: NextRequest) => {
- void request;
+export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "integrations.withings.resume" } });
diff --git a/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx b/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx
index 90bbf63fc..833c6560d 100644
--- a/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx
+++ b/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx
@@ -65,8 +65,11 @@ describe(" v1.4.43 M4 disabled-row behaviour", () => {
);
expect(html).toMatch(/data-unavailable="true"[\s\S]*?Sleep duration/);
- // The strike-through class fires for at least one disabled row.
- expect(html).toMatch(/line-through[\s\S]*?Sleep duration/);
+ // v1.4.43 W10 design-M4 — disabled rows render with muted text
+ // colour (not strike-through, which reads as "deleted" rather
+ // than "unavailable"). The italic hint line below carries the
+ // semantic visually.
+ expect(html).toMatch(/text-muted-foreground[\s\S]*?Sleep duration/);
});
it("renders the unavailable-hint tooltip text via the title attribute", () => {
diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx
index d730002c6..ad10befec 100644
--- a/src/components/doctor-report/doctor-report-dialog.tsx
+++ b/src/components/doctor-report/doctor-report-dialog.tsx
@@ -571,8 +571,8 @@ export function SectionToggles({
{t("doctorReport.sections.title")}
);
@@ -639,7 +639,7 @@ export function SectionToggles({
{t(SECTION_LABEL_KEYS[key])}
diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx
index ae230666f..846e99a9d 100644
--- a/src/components/insights/daily-briefing.tsx
+++ b/src/components/insights/daily-briefing.tsx
@@ -227,10 +227,10 @@ function BriefingSkeleton() {
aria-hidden="true"
>
{[0, 1, 2].map((i) => (
@@ -238,7 +238,7 @@ function BriefingSkeleton() {
key={i}
className="border-border/40 bg-card/30 flex h-16 items-center rounded-md border p-3"
>
-
+
))}
diff --git a/src/components/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx
index b16945b7d..3ebdc243a 100644
--- a/src/components/insights/trend-annotation.tsx
+++ b/src/components/insights/trend-annotation.tsx
@@ -97,9 +97,9 @@ export function TrendAnnotation({
className="border-border/60 bg-card/40 space-y-1.5 rounded-md border p-3 motion-reduce:animate-none"
aria-label={t("insights.trendAnnotation.pendingLabel")}
>
-
-
-
+
+
+
);
}
diff --git a/src/components/onboarding/WelcomeCarousel.tsx b/src/components/onboarding/WelcomeCarousel.tsx
index 3f2899ee6..b97146bd6 100644
--- a/src/components/onboarding/WelcomeCarousel.tsx
+++ b/src/components/onboarding/WelcomeCarousel.tsx
@@ -16,6 +16,7 @@ import {
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { readError } from "@/lib/api/read-error";
+import { scrollBehaviorForUser } from "@/lib/motion";
import { cn } from "@/lib/utils";
/**
@@ -119,11 +120,8 @@ export function WelcomeCarousel() {
`[data-slide-index="${idx}"]`,
);
if (!target) return;
- const reduceMotion = window.matchMedia(
- "(prefers-reduced-motion: reduce)",
- ).matches;
target.scrollIntoView({
- behavior: reduceMotion ? "auto" : "smooth",
+ behavior: scrollBehaviorForUser(),
inline: "start",
block: "nearest",
});
diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx
index e2941b446..daff86a60 100644
--- a/src/components/ui/switch.tsx
+++ b/src/components/ui/switch.tsx
@@ -20,10 +20,12 @@ function Switch({
// v1.4.43 W5-H1 — extend the WCAG 2.5.5 hit target to 44×44 px via
// a `::before` pseudo-element while leaving the visual track at
// 18×32 px. The same trick is used on Input (v1.4.34 IW-G). The
- // wrapping `relative` and `before:absolute … before:inset-[-12px]`
+ // wrapping `relative` and `before:absolute … before:inset-[-13px]`
// pair guarantees pointer events bubble up from the larger zone
- // without disturbing layout.
- "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none before:absolute before:inset-[-12px] before:content-[''] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
+ // without disturbing layout. v1.4.43 W10 design-M1 — `-13px` (not
+ // `-12px`) takes the 18.4 px track + 26 px padding to 44.4 px and
+ // closes the 1.6 px vertical short fall to spec-strict 2.5.5.
+ "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none before:absolute before:inset-[-13px] before:content-[''] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
className,
)}
{...props}
diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts
index fc8a94748..fe6c3a338 100644
--- a/src/lib/auth/passkey.ts
+++ b/src/lib/auth/passkey.ts
@@ -42,6 +42,31 @@ const authenticationResponseSchema = z
})
.loose();
+// v1.4.43 W10 senior-dev L-1 — symmetric Zod narrowing in front of
+// `verifyRegistration`. SimpleWebAuthn's `RegistrationResponseJSON`
+// shape: https://w3c.github.io/webauthn/#dictdef-registrationresponsejson
+// `attestationObject` replaces `authenticatorData + signature` from
+// the authentication shape; everything else mirrors the auth schema.
+const registrationResponseSchema = z
+ .object({
+ id: z.string().min(1),
+ rawId: z.string().min(1),
+ response: z
+ .object({
+ clientDataJSON: z.string().min(1),
+ attestationObject: z.string().min(1),
+ transports: z.array(z.string()).optional(),
+ publicKeyAlgorithm: z.number().optional(),
+ publicKey: z.string().optional(),
+ authenticatorData: z.string().optional(),
+ })
+ .loose(),
+ authenticatorAttachment: z.string().optional(),
+ clientExtensionResults: z.unknown().optional(),
+ type: z.literal("public-key"),
+ })
+ .loose();
+
type Transport =
| "ble"
| "cable"
@@ -145,12 +170,18 @@ export async function verifyRegistration(
}
try {
- // The caller passes a parsed JSON body without prior shape-validation —
- // the SimpleWebAuthn verifier owns full schema validation and throws
- // on any mismatch. We narrow at the boundary so the rest of the file
- // sees the documented `RegistrationResponseJSON` shape.
+ // v1.4.43 W10 senior-dev L-1 — Zod narrow at the boundary before
+ // delegating to SimpleWebAuthn's full cryptographic validation.
+ // A malformed body now fails fast with a structured Zod error
+ // rather than crashing on a follow-up `.id` deref deeper in the
+ // verifier. Mirrors the v1.4.43 W13 L-3 narrowing on the
+ // authentication side.
+ const parsed = registrationResponseSchema.safeParse(response);
+ if (!parsed.success) {
+ throw new Error("Registration response shape invalid");
+ }
const verification = await verifyRegistrationResponse({
- response: response as RegistrationResponseJSON,
+ response: parsed.data as RegistrationResponseJSON,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
diff --git a/tests/integration/coach-chat.test.ts b/tests/integration/coach-chat.test.ts
index 947c3c26f..8ea8e62e5 100644
--- a/tests/integration/coach-chat.test.ts
+++ b/tests/integration/coach-chat.test.ts
@@ -447,7 +447,7 @@ describe("POST /api/insights/chat — integration", () => {
// Audit row is the durable observable signal — assert it landed
// with the conversation id + turn index, no message content.
const auditRows = await prisma.auditLog.findMany({
- where: { action: "audit.coach.replay-injection" },
+ where: { action: "insights.coach.replay_injection" },
});
expect(auditRows).toHaveLength(1);
expect(auditRows[0].userId).toBe(userId);