diff --git a/messages/de.json b/messages/de.json
index bb63290f5..916b8b9e4 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4666,6 +4666,7 @@
"alwaysOn": "Immer an: {modules} und die Kernwerte (Gewicht, Blutdruck, Puls).",
"everythingElse": "Alles andere bleibt einen Klick entfernt unter",
"modulesLink": "Einstellungen, Module",
+ "switchesOff": "Nicht mehr in der Navigation: {modules}. Jederzeit unter Einstellungen, Module wieder einschaltbar.",
"glucoseUnit": "Blutzucker",
"unitPreference": "Einheiten",
"language": "Sprache",
diff --git a/messages/en.json b/messages/en.json
index 2ecd85fdf..7b6af9ac1 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4666,6 +4666,7 @@
"alwaysOn": "Always on: {modules}, and the core readings (weight, blood pressure, pulse).",
"everythingElse": "Everything else stays one click away under",
"modulesLink": "Settings, Modules",
+ "switchesOff": "Out of the navigation: {modules}. You can switch any of them back on under Settings, Modules.",
"glucoseUnit": "Blood glucose",
"unitPreference": "Units",
"language": "Language",
diff --git a/messages/es.json b/messages/es.json
index 527610e38..8325c73db 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4666,6 +4666,7 @@
"alwaysOn": "Siempre activos: {modules} y las mediciones básicas (peso, presión arterial, pulso).",
"everythingElse": "Todo lo demás queda a un clic en",
"modulesLink": "Ajustes, Módulos",
+ "switchesOff": "Fuera de la navegación: {modules}. Puedes volver a activarlos cuando quieras en Ajustes, Módulos.",
"glucoseUnit": "Glucosa en sangre",
"unitPreference": "Unidades",
"language": "Idioma",
diff --git a/messages/fr.json b/messages/fr.json
index b8c3b73d3..64da413da 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4666,6 +4666,7 @@
"alwaysOn": "Toujours actifs : {modules}, et les mesures de base (poids, tension, pouls).",
"everythingElse": "Tout le reste reste à un clic dans",
"modulesLink": "Réglages, Modules",
+ "switchesOff": "Hors de la navigation : {modules}. Tu peux les réactiver à tout moment dans Réglages, Modules.",
"glucoseUnit": "Glycémie",
"unitPreference": "Unités",
"language": "Langue",
diff --git a/messages/it.json b/messages/it.json
index a1ceb9da9..c5a336421 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4666,6 +4666,7 @@
"alwaysOn": "Sempre attivi: {modules} e le misurazioni di base (peso, pressione, polso).",
"everythingElse": "Tutto il resto resta a un clic in",
"modulesLink": "Impostazioni, Moduli",
+ "switchesOff": "Fuori dalla navigazione: {modules}. Puoi riattivarli quando vuoi in Impostazioni, Moduli.",
"glucoseUnit": "Glicemia",
"unitPreference": "Unità",
"language": "Lingua",
diff --git a/messages/ko.json b/messages/ko.json
index 7772b8576..727b99b4a 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -4666,6 +4666,7 @@
"alwaysOn": "항상 켜짐: {modules}, 그리고 기본 측정값(체중, 혈압, 맥박).",
"everythingElse": "나머지는 한 번의 클릭으로 열 수 있습니다:",
"modulesLink": "설정, 모듈",
+ "switchesOff": "탐색에서 빠집니다: {modules}. 설정, 모듈에서 언제든 다시 켤 수 있습니다.",
"glucoseUnit": "혈당",
"unitPreference": "단위",
"language": "언어",
diff --git a/messages/pl.json b/messages/pl.json
index cff8897c2..ee155e164 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4666,6 +4666,7 @@
"alwaysOn": "Zawsze włączone: {modules} oraz podstawowe pomiary (waga, ciśnienie, tętno).",
"everythingElse": "Cała reszta jest o jedno kliknięcie w",
"modulesLink": "Ustawienia, Moduły",
+ "switchesOff": "Znika z nawigacji: {modules}. W każdej chwili możesz je włączyć w Ustawienia, Moduły.",
"glucoseUnit": "Glukoza we krwi",
"unitPreference": "Jednostki",
"language": "Język",
diff --git a/src/__tests__/profile-rejection-surface-guard.test.ts b/src/__tests__/profile-rejection-surface-guard.test.ts
index 7bed465fd..e18fec0d0 100644
--- a/src/__tests__/profile-rejection-surface-guard.test.ts
+++ b/src/__tests__/profile-rejection-surface-guard.test.ts
@@ -31,12 +31,19 @@ const SRC = join(process.cwd(), "src");
const PROFILE_ROUTES = ["/api/auth/profile", "/api/user/profile"];
function sourceFiles(): string[] {
- return walkSourceFiles(SRC, { floor: 3000 })
- .filter((p) => !p.startsWith("generated/"))
- .filter((p) => !p.startsWith("app/api/"))
- .filter((p) => !p.includes("__tests__"))
- .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
- .sort();
+ return (
+ walkSourceFiles(SRC, { floor: 3000 })
+ .filter((p) => !p.startsWith("generated/"))
+ .filter((p) => !p.startsWith("app/api/"))
+ // v1.39 (Wave C, C6) — the edge proxy names routes and methods as DATA
+ // (the demo-mode mutation allowlist is a list of path/method pairs), and
+ // a pair reads to the matcher below exactly like a request expression. It
+ // submits nothing and has no answer to read.
+ .filter((p) => p !== "proxy.ts")
+ .filter((p) => !p.includes("__tests__"))
+ .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
+ .sort()
+ );
}
function read(rel: string): string {
diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts
index d51c61082..0f7a6af0d 100644
--- a/src/__tests__/proxy-demo-mode-allowlist.test.ts
+++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts
@@ -106,6 +106,36 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
expect(missing, "onboarding writes the demo cannot make").toEqual([]);
});
+ it("permits the baseline writes the confirm screen makes (M-m)", () => {
+ // v1.39 (Wave C, C6) — a demo visitor who typed a height got a 403 and a
+ // generic toast, and "someone I look after" could only take "Finish
+ // without the profile": the three writes the confirm screen makes were
+ // not on the list. None of them is health data — the account's own
+ // profile fields, the encrypted self-context the anamnesis card writes,
+ // and the managed record the flow's guardian arm creates.
+ for (const [path, method] of [
+ ["/api/auth/profile", "PUT"],
+ ["/api/coach/about-me", "PUT"],
+ ["/api/managed-profiles", "POST"],
+ ] as const) {
+ expect(
+ proxy(makeRequest(path, method)).status,
+ `${method} ${path} is blocked in the demo`,
+ ).not.toBe(403);
+ }
+ });
+
+ it("does not drag in a sibling verb on the baseline paths", () => {
+ expect(proxy(makeRequest("/api/auth/profile", "DELETE")).status).toBe(403);
+ expect(proxy(makeRequest("/api/managed-profiles", "DELETE")).status).toBe(
+ 403,
+ );
+ // The per-profile routes are a different path and stay closed.
+ expect(
+ proxy(makeRequest("/api/managed-profiles/abc", "DELETE")).status,
+ ).toBe(403);
+ });
+
it("still blocks a health-data mutation (POST /api/measurements)", () => {
const res = proxy(makeRequest("/api/measurements", "POST"));
expect(res.status).toBe(403);
diff --git a/src/components/onboarding/__tests__/confirm-failure-recovery.test.ts b/src/components/onboarding/__tests__/confirm-failure-recovery.test.ts
new file mode 100644
index 000000000..0fae32763
--- /dev/null
+++ b/src/components/onboarding/__tests__/confirm-failure-recovery.test.ts
@@ -0,0 +1,155 @@
+/**
+ * v1.39 (Wave C, C2) — a failed completion leaves the confirm screen usable.
+ *
+ * Two defects, one symptom. `advance()` in the baseline form set its pending
+ * flag and never reset it on the success path, and `finish()` on the confirm
+ * screen caught its own error and returned normally — so a failed
+ * `POST /api/onboarding/complete` (or a 429 on the answer PATCHes before it)
+ * left "Überspringen" and "Bestätigen und weiter" disabled for the rest of the
+ * page's life and only a reload recovered (research I10).
+ *
+ * The second defect is the retry itself: `finish()` fires one PATCH per
+ * question the flow never showed, and a failure midway left a partly-passed
+ * ledger that the next attempt walked from the top again (M-j).
+ *
+ * Both are pinned at the seam the component drives, because this repo's
+ * component tests render server-side and cannot click.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import {
+ defaultOnboardingSteps,
+ emptyOnboardingNeeds,
+ type OnboardingStateDto,
+} from "@/lib/onboarding/needs";
+
+import { runBaselineAttempt } from "../baseline-form";
+import { runConfirmFinish } from "../confirm-screen";
+
+type PassableStep = Parameters<
+ Parameters[0]["passStep"]
+>[0];
+
+/**
+ * A flow with no unit-bearing area: Q6 is the question the machine never
+ * showed, so the confirm screen has to pass it on the ledger before the
+ * completion.
+ */
+function state(
+ overrides: Partial = {},
+): OnboardingStateDto {
+ return {
+ steps: defaultOnboardingSteps().map((step) =>
+ step.id === "who" ? { ...step, status: "done" as const } : step,
+ ),
+ needs: { ...emptyOnboardingNeeds(), recordTarget: "me" },
+ completedAt: null,
+ firstResult: null,
+ ...overrides,
+ };
+}
+
+/** The ledger as the server would answer it after one step was passed. */
+function withPassed(
+ base: OnboardingStateDto,
+ ids: readonly string[],
+): OnboardingStateDto {
+ return {
+ ...base,
+ steps: base.steps.map((step) =>
+ ids.includes(step.id) ? { ...step, status: "skipped" as const } : step,
+ ),
+ };
+}
+
+describe("runBaselineAttempt", () => {
+ it("hands the buttons back when the completion rejects", async () => {
+ const pending: boolean[] = [];
+ const onError = vi.fn();
+ await runBaselineAttempt(
+ (value) => pending.push(value),
+ async () => {
+ throw new Error("complete failed");
+ },
+ onError,
+ );
+ expect(pending).toEqual([true, false]);
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ it("hands the buttons back on the success path too", async () => {
+ const pending: boolean[] = [];
+ await runBaselineAttempt(
+ (value) => pending.push(value),
+ async () => {},
+ vi.fn(),
+ );
+ expect(pending).toEqual([true, false]);
+ });
+
+ it("is the form's only way to move the pending flag", () => {
+ // A second, inline `setSaving(false)` is how the flag stopped being
+ // reset on one of the three exits in the first place.
+ const source = readFileSync(
+ join(process.cwd(), "src/components/onboarding/baseline-form.tsx"),
+ "utf8",
+ );
+ expect(source).not.toContain("setSaving(false)");
+ expect(source).not.toContain("setSaving(true)");
+ });
+});
+
+describe("runConfirmFinish", () => {
+ it("rethrows a failed completion instead of returning normally", async () => {
+ const ledger = { current: state() };
+ const passed: PassableStep[] = [];
+ const navigate = vi.fn();
+ await expect(
+ runConfirmFinish({
+ ledger,
+ // The server answers the written ledger; only the runner may put it
+ // back, which is the whole point of the ref.
+ passStep: async (step) => {
+ passed.push(step);
+ return withPassed(ledger.current, [step]);
+ },
+ complete: async () => {
+ throw new Error("429");
+ },
+ navigate,
+ }),
+ ).rejects.toThrow("429");
+ // Q6 was never shown for a flow with no unit-bearing area, so it is
+ // passed on the ledger before the completion — and that write survived.
+ expect(passed).toEqual(["units"]);
+ expect(navigate).not.toHaveBeenCalled();
+ });
+
+ it("does not pass a step the failed attempt already passed (M-j)", async () => {
+ const ledger = { current: state() };
+ const passed: PassableStep[] = [];
+ const passStep = async (step: PassableStep) => {
+ passed.push(step);
+ return withPassed(ledger.current, [step]);
+ };
+ let attempt = 0;
+ const complete = async () => {
+ attempt += 1;
+ if (attempt === 1) throw new Error("429");
+ return { onboarding: ledger.current };
+ };
+ const navigate = vi.fn();
+
+ await expect(
+ runConfirmFinish({ ledger, passStep, complete, navigate }),
+ ).rejects.toThrow("429");
+ await runConfirmFinish({ ledger, passStep, complete, navigate });
+
+ // "units" exactly once across both attempts; "first-result" is the
+ // second attempt's own pass, because these answers offer no task.
+ expect(passed).toEqual(["units", "first-result"]);
+ expect(navigate).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/onboarding/__tests__/confirm-screen.test.tsx b/src/components/onboarding/__tests__/confirm-screen.test.tsx
new file mode 100644
index 000000000..274a05b74
--- /dev/null
+++ b/src/components/onboarding/__tests__/confirm-screen.test.tsx
@@ -0,0 +1,109 @@
+/**
+ * v1.39 (Wave C) — what the confirm screen is allowed to claim.
+ *
+ * C5 (research M-l): both of the screen's own Settings links are page routes,
+ * and `hl_onboarding=pending` is still set while this screen is on — the
+ * completion is what clears it, and this screen is what calls the completion.
+ * The proxy sends both straight back to the flow, so on a first run they are
+ * dead links under a sentence that is not yet true.
+ *
+ * C4 (research I6): the screen listed what the answers switch ON and said
+ * everything else "stays one click away under Settings, Modules" — true of
+ * reachability, false of the navigation entry. A module the answers do not
+ * ask for leaves the navigation, and nothing said so.
+ *
+ * Rendered server-side, this repo's component-test convention.
+ */
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import type { AuthUser } from "@/hooks/use-auth";
+import {
+ defaultOnboardingSteps,
+ emptyOnboardingNeeds,
+ type OnboardingStateDto,
+} from "@/lib/onboarding/needs";
+
+let currentUser: AuthUser | null = null;
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+}));
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({ user: currentUser }),
+ useAccountOnceMounted: () => currentUser,
+}));
+
+const { ConfirmScreen } = await import("../confirm-screen");
+
+function state(
+ overrides: Partial = {},
+ needs: Partial = {},
+): OnboardingStateDto {
+ return {
+ steps: defaultOnboardingSteps(),
+ needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs },
+ completedAt: null,
+ firstResult: null,
+ ...overrides,
+ };
+}
+
+function render(
+ flow: OnboardingStateDto,
+ modules: Partial> = {},
+): string {
+ currentUser = {
+ id: "u1",
+ heightCm: null,
+ dateOfBirth: null,
+ gender: null,
+ timezone: "Europe/Berlin",
+ unitPreference: "metric",
+ modules,
+ } as unknown as AuthUser;
+ return renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+}
+
+describe(" — modules leaving the navigation (C4)", () => {
+ it("names them when the answers do not ask for what the record shows", () => {
+ const html = render(state({}, { areas: ["sleep"], medication: "no" }), {
+ medications: true,
+ });
+ expect(html).toContain('data-slot="onboarding-confirm-modules-off"');
+ expect(html).toContain("Medications");
+ });
+
+ it("says nothing when the answers take nothing away", () => {
+ const html = render(state({}, { areas: ["sleep"], medication: "yes" }), {
+ medications: true,
+ });
+ expect(html).not.toContain('data-slot="onboarding-confirm-modules-off"');
+ });
+});
+
+describe(" — Settings links on a first run (C5)", () => {
+ it("renders both as plain text while the completion has not run", () => {
+ const html = render(state());
+ expect(html).not.toContain('href="/settings/modules"');
+ expect(html).not.toContain('href="/settings/account"');
+ // The words stay: they say where the thing lives, which is true.
+ expect(html).toContain("Settings, Modules");
+ expect(html).toContain("Settings, Account");
+ });
+
+ it("renders them as links once the flow has been completed once", () => {
+ const html = render(state({ completedAt: "2026-09-10T08:00:00.000Z" }), {});
+ expect(html).toContain('href="/settings/modules"');
+ expect(html).toContain('href="/settings/account"');
+ });
+});
diff --git a/src/components/onboarding/__tests__/getting-started-checklist.test.tsx b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx
new file mode 100644
index 000000000..cfe0c20e9
--- /dev/null
+++ b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx
@@ -0,0 +1,139 @@
+/**
+ * v1.39 (Wave C, C1) — the checklist's data queries follow the same rule as
+ * its visibility.
+ *
+ * `shouldShowChecklist` gained `hasEnteredOnboardingFlow` as a third
+ * disjunct; `checklistRelevant` in the component kept restating the older
+ * half of the rule, so a wizard-run account past five readings still saw the
+ * card while every supporting query was `enabled: false` — the medication,
+ * data-source, notification and insights rows read "not done" for things the
+ * account had already done (research I9).
+ *
+ * Rendered SSR-only, per this repo's component-test convention. `useQuery` is
+ * mocked to honour `enabled` exactly the way tanstack does — a disabled query
+ * has no data — so what the rows say here is what the dashboard says.
+ */
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import type { AuthUser } from "@/hooks/use-auth";
+import {
+ defaultOnboardingSteps,
+ emptyOnboardingNeeds,
+ type OnboardingStateDto,
+} from "@/lib/onboarding/needs";
+
+let currentUser: AuthUser | null = null;
+/** Every `enabled` flag the component asked for, keyed by its query key. */
+const enabledByKey = new Map();
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAccountOnceMounted: () => currentUser,
+}));
+
+vi.mock("@/lib/queries/use-dashboard-snapshot", () => ({
+ useDashboardSnapshot: () => ({
+ data: { tiles: { summaries: { weight: { count: 12 } } } },
+ }),
+}));
+
+const RESPONSES: Record = {
+ '["medications"]': [{ id: "m1" }, { id: "m2" }, { id: "m3" }],
+ '["user","ai-provider"]': { aiAvailable: true },
+ '["integrations","status"]': {
+ integrations: [{ integration: "whoop", connected: true }],
+ },
+ // Deliberately NOT configured: one open row keeps the card on screen, so
+ // the rows this test is about are actually rendered.
+ '["notifications","preferences"]': { channels: [{ enabled: false }] },
+};
+
+vi.mock("@tanstack/react-query", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useQuery: (options: {
+ queryKey: readonly unknown[];
+ enabled?: boolean;
+ }) => {
+ const key = JSON.stringify(options.queryKey);
+ const enabled = options.enabled !== false;
+ enabledByKey.set(key, enabled);
+ return { data: enabled ? RESPONSES[key] : undefined };
+ },
+ };
+});
+
+const { GettingStartedChecklist } =
+ await import("../getting-started-checklist");
+
+/** A record the flow really finished: Q1 answered, confirmed, task settled. */
+function settledOnboarding(): OnboardingStateDto {
+ return {
+ steps: defaultOnboardingSteps().map((step) => ({
+ ...step,
+ status: "done" as const,
+ })),
+ needs: { ...emptyOnboardingNeeds(), recordTarget: "me" },
+ completedAt: "2026-09-10T08:00:00.000Z",
+ firstResult: {
+ task: "log-reading",
+ target: null,
+ completedAt: "2026-09-10T08:05:00.000Z",
+ },
+ } as OnboardingStateDto;
+}
+
+function user(overrides: Partial = {}): AuthUser {
+ return {
+ id: "u1",
+ email: "a@b.c",
+ username: "a",
+ heightCm: 175,
+ dateOfBirth: "1990-01-01T00:00:00.000Z",
+ gender: "MALE",
+ onboardingCompletedAt: "2026-09-10T08:00:00.000Z",
+ onboarding: settledOnboarding(),
+ ...overrides,
+ } as AuthUser;
+}
+
+function render(): string {
+ enabledByKey.clear();
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+describe(" — rows for an established account", () => {
+ it("reads the real medication, data-source and insights state once the flow is behind the account", () => {
+ currentUser = user();
+ const html = render();
+
+ // The card is on screen: the notifications row is genuinely open. The
+ // list itself is collapsed on a server paint, so the progress meter is
+ // what the rows add up to — profile, measurement, medication, data
+ // source and insights done, notifications not.
+ expect(html).toContain('data-testid="onboarding-card"');
+ expect(html).toContain("5 of 6 done");
+ });
+
+ it("keeps the supporting queries enabled while the card can render", () => {
+ currentUser = user();
+ render();
+ expect(enabledByKey.get('["medications"]')).toBe(true);
+ expect(enabledByKey.get('["integrations","status"]')).toBe(true);
+ expect(enabledByKey.get('["notifications","preferences"]')).toBe(true);
+ expect(enabledByKey.get('["user","ai-provider"]')).toBe(true);
+ });
+
+ it("still fetches nothing for a record that never entered the flow and is long past five readings", () => {
+ currentUser = user({ onboarding: null });
+ render();
+ expect(enabledByKey.get('["medications"]')).toBe(false);
+ expect(enabledByKey.get('["integrations","status"]')).toBe(false);
+ });
+});
diff --git a/src/components/onboarding/__tests__/units-screen.test.tsx b/src/components/onboarding/__tests__/units-screen.test.tsx
new file mode 100644
index 000000000..9dfeb5de5
--- /dev/null
+++ b/src/components/onboarding/__tests__/units-screen.test.tsx
@@ -0,0 +1,82 @@
+/**
+ * v1.39 (Wave C, C3) — Q6 cannot be answered with nothing chosen (M-k).
+ *
+ * "Weiter" used to be live on an untouched units screen: it wrote
+ * `{ units: {} }`, which marked the step DONE without recording a preference,
+ * so the account carried "asked and answered" for a question nobody answered.
+ * Skip is the way past a question with no opinion, and Skip says so on the
+ * ledger.
+ *
+ * Rendered server-side, this repo's component-test convention.
+ */
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import {
+ defaultOnboardingSteps,
+ emptyOnboardingNeeds,
+ type OnboardingStateDto,
+} from "@/lib/onboarding/needs";
+
+import { UnitsScreen } from "../units-screen";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+}));
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({ user: null }),
+}));
+
+function state(
+ needs: Partial,
+): OnboardingStateDto {
+ return {
+ steps: defaultOnboardingSteps(),
+ needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs },
+ completedAt: null,
+ firstResult: null,
+ };
+}
+
+function render(needs: Partial): string {
+ return renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+}
+
+/**
+ * Is the primary button's own tag disabled? Read off the tag rather than the
+ * whole page, and off the attribute rather than the string "disabled", which
+ * also occurs in the button's `disabled:` utility classes.
+ */
+function nextIsDisabled(html: string): boolean {
+ const at = html.indexOf('data-slot="onboarding-next"');
+ expect(at).toBeGreaterThan(-1);
+ const open = html.lastIndexOf("