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 <surface>.<verb> 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).
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 23:06:13 +02:00
1 parent f1e16bf713
commit 468e38c3ef
10 files changed
+66 -33

No files matched your search

+5 -3
View File
@@ -201,10 +201,12 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// 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 `<surface>.<verb>`
// 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<Response> {
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,
@@ -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" } });
@@ -65,8 +65,11 @@ describe("<SectionToggles> 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", () => {
@@ -571,8 +571,8 @@ export function SectionToggles({
{t("doctorReport.sections.title")}
</p>
<div className="space-y-2">
<div className="bg-muted h-9 animate-pulse rounded-md" />
<div className="bg-muted h-9 animate-pulse rounded-md" />
<div className="bg-muted h-9 animate-pulse motion-reduce:animate-none rounded-md" />
<div className="bg-muted h-9 animate-pulse motion-reduce:animate-none rounded-md" />
</div>
</div>
);
@@ -639,7 +639,7 @@ export function SectionToggles({
<span className="flex flex-col">
<span
className={`text-sm leading-tight font-medium ${
isAvailable ? "" : "line-through"
isAvailable ? "" : "text-muted-foreground"
}`}
>
{t(SECTION_LABEL_KEYS[key])}
+5 -5
View File
@@ -227,10 +227,10 @@ function BriefingSkeleton() {
aria-hidden="true"
>
<div className="space-y-2">
<div className="bg-muted/60 h-3 w-11/12 animate-pulse rounded" />
<div className="bg-muted/60 h-3 w-10/12 animate-pulse rounded" />
<div className="bg-muted/60 h-3 w-9/12 animate-pulse rounded" />
<div className="bg-muted/60 h-3 w-8/12 animate-pulse rounded" />
<div className="bg-muted/60 h-3 w-11/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-3 w-10/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-3 w-9/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-3 w-8/12 animate-pulse motion-reduce:animate-none rounded" />
</div>
<div className="space-y-2">
{[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"
>
<div className="bg-muted/60 h-3 w-1/3 animate-pulse rounded" />
<div className="bg-muted/60 h-3 w-1/3 animate-pulse motion-reduce:animate-none rounded" />
</div>
))}
</div>
+3 -3
View File
@@ -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")}
>
<div className="bg-muted/60 h-2.5 w-11/12 animate-pulse rounded" />
<div className="bg-muted/60 h-2.5 w-9/12 animate-pulse rounded" />
<div className="bg-muted/60 h-2.5 w-7/12 animate-pulse rounded" />
<div className="bg-muted/60 h-2.5 w-11/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-2.5 w-9/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-2.5 w-7/12 animate-pulse motion-reduce:animate-none rounded" />
</div>
);
}
@@ -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",
});
+5 -3
View File
@@ -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}
+36 -5
View File
@@ -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(),
+1 -1
View File
@@ -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);