revert(settings): restore the flat settings navigation

Revert the nine-group consolidation and the card relocations back to the
flat sidebar: sessions, trusted devices, and login activity return to
Privacy, and account deletion returns inline under Advanced. Drop the
/settings/layout redirect and the now-unused group and sign-in cross-link
i18n keys across all six locales.

Keep the collapse-by-default sessions and activity cards, the medical-history
cross-link from "About me", and the chronic-conditions consolidation.
This commit is contained in:
Marc-André Bombeck committed 2026-06-29 07:12:10 +02:00
1 parent 7bd26ff2bd
commit f564cdeef7
19 files changed
+518 -1133

No files matched your search

@@ -1,20 +1,16 @@
/**
* v1.4.43 QoL (M3 + L5) — danger-zone shaping.
* v1.4.43 QoL (M3 + L5) — `<AdvancedSection>` danger-zone shaping.
*
* M3: a separate "Delete account entirely" destructive card
* (`AccountDeleteCard`) gives the GDPR Article 17 full-erasure CTA its
* own surface alongside the half-erasure data reset.
* (`AccountDeleteCard`) sits beside the existing `DataResetCard`
* so a user reading the GDPR Article 17 surface has the full
* erasure CTA right next to the half-erasure one.
*
* L5: the data-reset card no longer paints a red `AlertTriangle`
* icon and the title sits in the neutral foreground colour
* (mirrors GitHub's danger-zone shaping). The CTA button stays
* red; the protective confirmation dialog stays unchanged.
*
* v1.25.1 (Q2-M3): account deletion moved out of `<AdvancedSection>` (Data &
* Privacy group) into the Account group as a standalone `<AccountDeleteCard>`.
* The L5 data-reset assertions still target `<AdvancedSection>`; the M3
* account-delete assertions now render the relocated card directly.
*
* SSR-only — we render the component statically and inspect the
* markup. The mutation handlers don't fire in SSR, so this test only
* pins the visible structure + copy.
@@ -38,7 +34,6 @@ vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({
}));
import { AdvancedSection } from "../advanced-section";
import { AccountDeleteCard } from "../account-delete-card";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
@@ -48,25 +43,11 @@ function render(locale: "en" | "de" = "en") {
);
}
function renderDeleteCard(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<AccountDeleteCard />
</I18nProvider>,
);
}
describe("<AdvancedSection> danger-zone shaping (v1.4.43 L5)", () => {
it("mounts the data-reset card", () => {
describe("<AdvancedSection> danger-zone shaping (v1.4.43 M3 + L5)", () => {
it("mounts both the data-reset card and the account-delete card", () => {
const html = render();
expect(html).toContain('data-slot="settings-data-reset-card"');
});
it("v1.25.1 (Q2-M3): no longer mounts the account-delete card", () => {
// Account deletion graduated to the Account group; Advanced keeps research
// mode + the data reset only.
const html = render();
expect(html).not.toContain('data-slot="settings-account-delete-card"');
expect(html).toContain('data-slot="settings-account-delete-card"');
});
it("L5: the data-reset card title is neutral foreground (no destructive red)", () => {
@@ -94,16 +75,9 @@ describe("<AdvancedSection> danger-zone shaping (v1.4.43 L5)", () => {
expect(cardMatch).not.toBeNull();
expect(cardMatch![0]).not.toMatch(/lucide-alert-triangle/);
});
});
describe("<AccountDeleteCard> (v1.4.43 M3, relocated v1.25.1 Q2-M3)", () => {
it("renders the account-delete card", () => {
const html = renderDeleteCard();
expect(html).toContain('data-slot="settings-account-delete-card"');
});
it("M3: the account-delete card uses the destructive Button variant", () => {
const html = renderDeleteCard();
const html = render();
expect(html).toContain('data-slot="settings-account-delete-trigger"');
// The trigger button still wears the destructive style — the CTA
// remains red even though the surrounding card chrome is neutral.
@@ -117,14 +91,14 @@ describe("<AccountDeleteCard> (v1.4.43 M3, relocated v1.25.1 Q2-M3)", () => {
});
it("M3: the account-delete card surfaces tight DE copy under the 'de' locale", () => {
const html = renderDeleteCard("de");
const html = render("de");
expect(html).toContain("Konto vollständig löschen");
// The descriptive sentence calls out the cascade scope plainly.
expect(html).toContain("Passkeys");
});
it("M3: the account-delete card surfaces tight EN copy under the 'en' locale", () => {
const html = renderDeleteCard("en");
const html = render("en");
expect(html).toContain("Delete account entirely");
expect(html).toContain("passkeys");
});
@@ -52,7 +52,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("<PrivacySection> — assembled Data & Privacy dashboard", () => {
it("renders the assembled data/retention sections and links out (no embedded sign-in cards)", () => {
it("renders the assembled sections (encryption, retention, export, delete, sessions, activity)", () => {
const html = render(<PrivacySection />);
// Encryption-at-rest block driven from the seeded summary.
expect(html).toContain("AES-256-GCM");
@@ -64,13 +64,11 @@ describe("<PrivacySection> — assembled Data & Privacy dashboard", () => {
expect(html).toContain('href="/settings/export"');
expect(html).toContain('href="/settings/gesundheitsakte"');
expect(html).toContain('href="/settings/advanced"');
// v1.25.1 (H1) — active sessions, trusted devices, and the security-activity
// feed moved to Account → Security; they are NOT embedded here anymore.
expect(html).not.toContain("settings-security-sessions-card");
expect(html).not.toContain("settings-trusted-devices-card");
expect(html).not.toContain("settings-security-activity-card");
// Instead this pane cross-links to the consolidated sign-in home.
expect(html).toContain('href="/settings/security"');
// Embedded active-session + security-activity cards.
expect(html).toContain("settings-security-sessions-card");
// Trusted devices now lives here (and only here) — its single home is the
// Privacy dashboard, not the Security section.
expect(html).toContain("settings-trusted-devices-card");
});
it("shows a loading placeholder for the retention block before the summary resolves", () => {
@@ -126,16 +126,12 @@ function renderFramed(
}
describe("settings sections — SSR smoke", () => {
it("<AccountSection> renders the Profile heading and Profile card", () => {
it("<AccountSection> renders the Account heading and Profile card", () => {
const html = renderFramed("account", <AccountSection />);
// v1.25.1 (Q2-M2) — the section title is now "Profile & preferences" (the
// first-child sub-tab no longer duplicates the "Account" group name). It
// resolves via the frame + i18n provider.
expect(html).toContain("Profile &amp; preferences");
// Section title resolves via the frame + i18n provider.
expect(html).toContain("Account");
// Profile card heading is also painted.
expect(html).toContain("Profile");
// v1.25.1 (Q2-M3) — account deletion moved here from Advanced.
expect(html).toContain('data-slot="settings-account-delete-card"');
// No raw key leaks past the i18n layer.
expect(html).not.toContain("settings.sections.");
});
@@ -188,9 +184,7 @@ describe("settings sections — SSR smoke", () => {
it("<IntegrationsSection> renders Withings card title", () => {
const html = renderFramed("integrations", <IntegrationsSection />);
// v1.25.1 (Q2-M2) — the section title is now "Connections" (the first-child
// sub-tab no longer duplicates the "Integrations" group name).
expect(html).toContain("Connections");
expect(html).toContain("Integrations");
expect(html).toContain("Withings");
});
@@ -29,17 +29,12 @@ vi.mock("@/hooks/use-auth", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import {
SETTINGS_GROUPS,
SETTINGS_SECTION_SLUGS,
SETTINGS_SECTIONS,
SettingsShell,
isSettingsSectionSlug,
} from "../settings-shell";
function count(html: string, re: RegExp): number {
return html.match(re)?.length ?? 0;
}
function renderShell(props: {
active?: (typeof SETTINGS_SECTION_SLUGS)[number];
pathname?: string;
@@ -155,212 +150,229 @@ describe("SETTINGS_SECTION_SLUGS", () => {
});
});
// v1.25.1 — the left rail renders NINE groups (mobile strip + desktop sidebar
// = two copies each). Every section route stays live at its own URL, but the
// rail is driven by `SETTINGS_GROUPS`; each group page surfaces its children
// as in-page sub-tabs (`data-slot="settings-subtab"`).
const GROUP_LANDINGS: Record<string, string> = {
account: "/settings/account",
tracking: "/settings/modules",
display: "/settings/dashboard",
integrations: "/settings/integrations",
notifications: "/settings/notifications",
ai: "/settings/ai",
access: "/settings/api",
data: "/settings/export",
about: "/settings/about",
};
describe("<SettingsShell>", () => {
it("renders all nine group nav entries — once per layout (mobile + desktop)", () => {
it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => {
const html = renderShell({ active: "account" });
expect(count(html, /data-slot="settings-group-nav-item"/g)).toBe(
SETTINGS_GROUPS.length * 2,
);
for (const group of SETTINGS_GROUPS) {
expect(count(html, new RegExp(`data-group="${group.id}"`, "g"))).toBe(2);
// Every slug — including `about`, which returned to the shell nav
// as the last entry after living dropdown-only since v1.4.33 IW7 —
// renders in both layouts.
const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug);
for (const slug of navigableSlugs) {
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
// Two renders — mobile strip + desktop sidebar — guarantee the link
// exists in both layouts. Tablet/desktop hide the strip with `md:`,
// but the markup is always present so it's keyboard-discoverable
// before media queries resolve.
expect(matches?.length ?? 0).toBe(2);
}
});
it("each group entry links to its landing (first visible child) route", () => {
it("links use the correct `/settings/<slug>` href — regression guard against typos", () => {
const html = renderShell({ active: "account" });
for (const [id, landing] of Object.entries(GROUP_LANDINGS)) {
const re = new RegExp(
`data-slot="settings-group-nav-item"[^>]*data-group="${id}"[^>]*href="${landing}"|href="${landing}"[^>]*data-slot="settings-group-nav-item"[^>]*data-group="${id}"`,
"g",
);
// React SSR attribute order is stable but we tolerate either ordering.
// Fall back to asserting the href is present on the group somewhere.
const present =
count(html, re) >= 2 || html.includes(`data-group="${id}"`) === true;
expect(present, `group ${id} → ${landing}`).toBe(true);
expect(html).toContain(`href="${landing}"`);
}
// Spot-check a couple of slugs that have historic anchor aliases (e.g.
// `/settings#withings` → `/settings/integrations`) — they must NOT
// sneak back in.
expect(html).toContain('href="/settings/integrations"');
expect(html).toContain('href="/settings/ai"');
expect(html).not.toContain('href="/settings#');
expect(html).not.toContain('href="/settings"');
});
it('marks the active GROUP with aria-current="page" (both layouts)', () => {
// On `/settings/security` the Account GROUP reads active (security is an
// Account child). Its rail entry points at the group landing.
const html = renderShell({ pathname: "/settings/security" });
const accountActive =
/data-group="account"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="account"/g;
expect(count(html, accountActive)).toBe(2);
it('marks the active section with aria-current="page" (and only that one)', () => {
const html = renderShell({ active: "notifications" });
// Both layouts emit the active link, so two `aria-current="page"`.
const occurrences = html.match(/aria-current="page"/g);
expect(occurrences?.length ?? 0).toBe(2);
// And the active link is on the notifications href. Attribute order
// in React SSR is alphabetic, so `aria-current` precedes `href` —
// the regex deliberately doesn't pin order to stay robust against
// future React renderer changes.
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|<a\b[^>]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
});
it("derives the active group from the current pathname when `active` is omitted", () => {
const html = renderShell({ pathname: "/settings/mcp" });
// mcp is a child of the API & Access group.
const accessActive =
/data-group="access"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="access"/g;
expect(count(html, accessActive)).toBe(2);
it("derives the active slug from the current pathname when `active` is omitted", () => {
const html = renderShell({ pathname: "/settings/api" });
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/api"|<a\b[^>]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
});
it("highlights the Display group on its child editors and the legacy layout hub", () => {
// Dashboard + Insights are Display sub-tabs; `/settings/layout` (the
// personalisation hub) stays a live route and also maps to Display.
for (const child of [
"/settings/dashboard",
"/settings/insights",
"/settings/layout",
]) {
it("highlights the Layout hub when on a Layout child editor (v1.17.1 F-2)", () => {
// On `/settings/dashboard` (a Layout child reached through the hub) the
// Layout nav entry must read active even though Dashboard has no nav
// entry of its own. Both layouts emit the active link → two matches.
for (const child of ["/settings/dashboard", "/settings/insights"]) {
const html = renderShell({ pathname: child });
const displayActive =
/data-group="display"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="display"/g;
expect(count(html, displayActive), child).toBe(2);
const layoutActive =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g;
expect(html.match(layoutActive)?.length ?? 0).toBe(2);
}
});
it("renders in-page sub-tabs for a multi-child group, marking the active child", () => {
it("highlights the Layout hub when the active prop is a Layout child", () => {
// The page passes `active={section}` explicitly; a child slug must
// still resolve onto the Layout hub for nav highlighting.
const html = renderShell({
active: "security",
pathname: "/settings/security",
active: "dashboard",
pathname: "/settings/dashboard",
});
// Account group → two sub-tabs (Profile + Security).
expect(html).toContain('data-subtab-slug="account"');
expect(html).toContain('data-subtab-slug="security"');
const securityTab =
/data-subtab-slug="security"[^>]*aria-current="page"|aria-current="page"[^>]*data-subtab-slug="security"/g;
expect(count(html, securityTab)).toBe(1);
const layoutActive =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g;
expect(html.match(layoutActive)?.length ?? 0).toBe(2);
});
it("renders NO sub-tab strip for a single-child group", () => {
// Notifications + About are standalone groups (one child each).
const html = renderShell({ pathname: "/settings/notifications" });
expect(html).not.toContain('data-slot="settings-subtabs"');
expect(html).not.toContain('data-slot="settings-subtab"');
});
it("module-gates a sub-tab off `user.modules` but keeps the group", () => {
// On a Tracking route with mood disabled: the Mood sub-tab is gone, the
// always-on sub-tabs remain, and the Tracking group entry still renders.
it("module-gates the Stimmung entry off `user.modules.mood` (v1.18.0 S5)", () => {
// Fail-open default: mood undefined → entry shown.
expect(renderShell({ active: "account" })).toContain(
'href="/settings/mood"',
);
// Explicitly disabled → entry hidden.
const disabled = renderShell({
active: "medications",
pathname: "/settings/medications",
active: "account",
modules: { mood: false },
});
expect(disabled).not.toContain('data-subtab-slug="mood"');
expect(disabled).toContain('data-subtab-slug="modules"');
expect(disabled).toContain('data-subtab-slug="thresholds"');
expect(count(disabled, /data-group="tracking"/g)).toBe(2);
// Fail-open default → mood sub-tab shown.
const open = renderShell({
active: "medications",
pathname: "/settings/medications",
expect(disabled).not.toContain('href="/settings/mood"');
// Explicitly enabled → entry shown, marked active on its own route.
const enabled = renderShell({
active: "mood",
pathname: "/settings/mood",
modules: { mood: true },
});
expect(open).toContain('data-subtab-slug="mood"');
const moodActive =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/mood"|<a\b[^>]*\bhref="\/settings\/mood"[^>]*\baria-current="page"/g;
expect(enabled.match(moodActive)?.length ?? 0).toBe(2);
});
it("module-gates the Coach sub-tab off `user.modules.coach`", () => {
const open = renderShell({ pathname: "/settings/ai" });
expect(open).toContain('data-subtab-slug="coach"');
it("module-gates the Coach entry off `user.modules.coach` (v1.18.0 S5)", () => {
// Fail-open default: coach undefined → entry shown.
expect(renderShell({ active: "account" })).toContain(
'href="/settings/coach"',
);
// Explicitly disabled → entry hidden.
const disabled = renderShell({
pathname: "/settings/ai",
active: "account",
modules: { coach: false },
});
expect(disabled).not.toContain('data-subtab-slug="coach"');
// AI single remaining child → no strip, but the group entry stays.
expect(count(disabled, /data-group="ai"/g)).toBe(2);
expect(disabled).not.toContain('href="/settings/coach"');
});
it("always surfaces the Health-record sub-tab under Data & Privacy", () => {
// The health record is a flagship export and is not nav-gated. On a Data
// & Privacy route it is always one of the sub-tabs.
const cases: Array<Record<string, boolean> | undefined> = [
undefined,
{},
{ doctorReport: false },
];
for (const modules of cases) {
const html = renderShell({ pathname: "/settings/export", modules });
expect(html).toContain('data-subtab-slug="gesundheitsakte"');
}
it("always surfaces the Health-record entry, regardless of `doctorReport` (v1.18.6.1)", () => {
// The health-record (PDF / FHIR) entry is a flagship export capability
// and is no longer nav-gated: a default/unset modules map shows it, and
// even an explicit `doctorReport: false` keeps the Settings entry-point
// reachable. The server-side `/api/export/health-record` route remains
// the hard enforcement of the opt-out.
expect(renderShell({ active: "account" })).toContain(
'href="/settings/gesundheitsakte"',
);
expect(renderShell({ active: "account", modules: {} })).toContain(
'href="/settings/gesundheitsakte"',
);
const optedOut = renderShell({
active: "account",
modules: { doctorReport: false },
});
expect(optedOut).toContain('href="/settings/gesundheitsakte"');
});
it("falls back to the Account group when the pathname doesn't match a known slug", () => {
it("falls back to `account` when the pathname doesn't match a known slug", () => {
const html = renderShell({ pathname: "/settings/totally-bogus" });
const accountActive =
/data-group="account"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="account"/g;
expect(count(html, accountActive)).toBe(2);
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/account"|<a\b[^>]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
});
it("resolves the nine group titles via the i18n provider — English", () => {
it("resolves every section title via the i18n provider — English", () => {
const html = renderShell({ active: "account", locale: "en" });
for (const title of [
"Account",
"Tracking",
"Display",
"Integrations",
"Notifications",
"AI &amp; Coach",
"API &amp; Access",
"Data &amp; Privacy",
"About",
]) {
expect(html, title).toContain(title);
}
// The fixed Display-group title resolves the v1.25.1 rename (it no longer
// collides with its own Dashboard child).
expect(html).toContain(">Display</a>");
expect(html).toContain("Account");
expect(html).toContain("Integrations");
// v1.9.0 — the section label is back to the shorter "Notifications"
// (single-line; the longer "Notification channels" wrapped). The
// `/notifications` inbox now shares the "Notifications" label.
expect(html).toContain("Notifications");
// v1.17.1 (F-2) — the dashboard + insights arrangement editors are
// reached through one hub entry (route stays `/settings/layout`).
// v1.19.1 (S3) — the nav label now reads "Dashboard" to match where
// it leads; the route is unchanged.
expect(html).toContain('href="/settings/layout"');
expect(html).toContain(">Dashboard</a>");
// v1.18.1 (D4) — Channels + Sources are their own left-side entries.
expect(html).toContain('href="/settings/channels"');
expect(html).toContain('href="/settings/sources"');
// v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung, gated;
// fail-open mock shows both) are their own nav entries now.
expect(html).toContain('href="/settings/medications"');
expect(html).toContain('href="/settings/mood"');
// The ampersand is HTML-escaped by React SSR — assert on the encoded
// form so we don't accidentally match a parser that double-escapes.
expect(html).toContain("API &amp; Tokens");
// v1.18.0 (S5) — the health record is its own top-level entry.
expect(html).toContain('href="/settings/gesundheitsakte"');
expect(html).toContain("Health record");
// v1.4.16 phase B7: the consolidated Export section is a top-level
// entry in the sidebar; the link must be present in both locales.
expect(html).toContain('href="/settings/export"');
expect(html).toContain("Advanced");
// About is back in the settings nav as the last entry (it also
// stays linked from the sidebar user-card dropdown).
expect(html).toContain('href="/settings/about"');
// v1.18.1 (D4) — Sources is a standalone nav entry again (split out of
// the Integrations sub-tabs). Targets keeps its own entry.
expect(html).toContain('href="/settings/thresholds"');
expect(html).toContain('href="/settings/sources"');
expect(html).toContain("Targets");
});
it("resolves the nine group titles via the i18n provider — German", () => {
it("resolves every section title via the i18n provider — German", () => {
const html = renderShell({ active: "account", locale: "de" });
for (const title of [
"Konto",
"Tracking",
"Darstellung",
"Integrationen",
"Benachrichtigungen",
"KI &amp; Coach",
"API &amp; Zugriff",
"Daten &amp; Datenschutz",
"Über",
]) {
expect(html, title).toContain(title);
}
expect(html).toContain("Konto");
expect(html).toContain("Integrationen");
// v1.9.0 — back to the shorter "Benachrichtigungen" (single-line; the
// compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications`
// inbox now shares the "Benachrichtigungen" label.
expect(html).toContain("Benachrichtigungen");
// v1.17.1 (F-2) — the arrangement editors are reached through one hub
// entry (route stays `/settings/layout`). v1.19.1 (S3) — the nav label
// reads "Dashboard" in every locale to match where it leads.
expect(html).toContain('href="/settings/layout"');
expect(html).toContain(">Dashboard</a>");
// v1.18.1 (D4) — Channels ("Kanäle") + Sources ("Quellen-Priorität")
// are their own left-side entries.
expect(html).toContain('href="/settings/channels"');
expect(html).toContain("Kanäle");
// v1.18.0 (S5) — Medications + Mood are their own nav entries now.
expect(html).toContain('href="/settings/medications"');
expect(html).toContain('href="/settings/mood"');
// Targets keeps its German nav entry ("Zielwerte").
expect(html).toContain("Zielwerte");
// v1.18.6 (W9) — the AI section is named "KI-Anbieter" in German: the
// page is about the provider / BYOK, not an "Auswertung".
expect(html).toContain("KI-Anbieter");
// API & Tokens is identical in both locales (proper noun + ampersand)
expect(html).toContain("API &amp; Tokens");
expect(html).toContain("Erweitert");
// "Über diese App" (About) is back as the last in-shell nav entry
// (the sidebar user-card dropdown keeps its own link too).
expect(html).toContain('href="/settings/about"');
// v1.18.1 (D4) — Sources is a standalone nav entry again.
expect(html).toContain('href="/settings/sources"');
});
it("does NOT surface a raw i18n key — guards against missing JSON entries", () => {
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
const html = renderShell({ active: "account", locale: "en" });
expect(html).not.toContain("settings.groups.");
expect(html).not.toContain("settings.sections.");
expect(html).not.toContain("settings.shell.");
});
it("emits the section heading `id` on exactly ONE instance (no duplicate-id-aria)", () => {
// v1.25.1 (A11Y M1) — the heading paints at two breakpoints (mobile above
// the strip, desktop in the grid); both stay in the DOM. The `id` (the
// `aria-labelledby` target) must appear once, while `data-settings-heading`
// (the focus-effect hook) stays on both instances.
const html = renderShell({ active: "account" });
expect(count(html, /id="settings-section-account-title"/g)).toBe(1);
expect(
count(html, /data-settings-heading="settings-section-account-title"/g),
).toBe(2);
});
it("mobile group strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => {
it("mobile section strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => {
// The 10-section strip is wider than 393 CSS px, so without
// `no-scrollbar` Chromium/WebKit paints an always-on horizontal
// scrollbar at the top of every settings page. The class is
// defined in `globals.css` and combines `scrollbar-width: none`
// (Firefox) with `::-webkit-scrollbar { display: none }` (everyone
// else) — scroll behaviour is preserved.
const html = renderShell({ active: "account" });
const nav = html.match(/<nav\b[^>]*md:hidden[^>]*>/);
expect(nav).not.toBeNull();