From d58116e3ffe7f1e44ca28e8a31066b6813cc014d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 12:46:15 +0200 Subject: [PATCH 1/3] fix(settings): pin the settings and admin sidebars on scroll The desktop section nav in both shells used a single sticky wrapper at top-20. Because the nav can run taller than the scroll viewport and the top offset assumed a topbar that sits outside the scroll region, the panel drifted with the content and re-pinned abruptly on scroll-up. Cap the wrapper height to the viewport and let it scroll independently (top-6, max-h-[calc(100dvh-5.5rem)], overflow-y-auto, no-scrollbar) so the nav stays pinned. Covers every /settings/* and /admin/* page through the shared shells. --- src/components/admin/admin-shell.tsx | 2 +- src/components/settings/settings-shell.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index f0bd0209c..39f94bb37 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -316,7 +316,7 @@ export function AdminShell({ active, children }: AdminShellProps) { aria-label={t("admin.shell.sectionsNav")} className="hidden md:col-start-1 md:row-start-2 md:block" > -
+
  • -
    +
      {visibleSections.map((section) => { const isActive = section.slug === activeSlug; From f4815fafe83cd6cca24f4e05c5863bdda553e7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 12:46:23 +0200 Subject: [PATCH 2/3] refactor(settings): drop the unused section heading frame SettingsSectionFrame re-rendered the page heading and subtitle that SettingsShell already paints from the section slug. No route used it; only the per-section smoke tests rendered bodies through it to assert the visible heading and its id. Remove the component and move the heading wrapper those tests rely on into a test-only harness that reproduces the shell's heading markup, so the smoke tests keep asserting what the user actually sees. --- .../settings/__tests__/coach-section.test.tsx | 2 +- .../__tests__/export-section.test.tsx | 2 +- .../gesundheitsakte-section.test.tsx | 2 +- .../__tests__/medications-section.test.tsx | 2 +- .../settings/__tests__/mood-section.test.tsx | 2 +- .../__tests__/section-frame-harness.tsx | 45 +++++++ .../settings/__tests__/sections.test.tsx | 2 +- .../settings/settings-section-frame.tsx | 113 ------------------ 8 files changed, 51 insertions(+), 119 deletions(-) create mode 100644 src/components/settings/__tests__/section-frame-harness.tsx delete mode 100644 src/components/settings/settings-section-frame.tsx diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx index 921dae86a..0a22bff56 100644 --- a/src/components/settings/__tests__/coach-section.test.tsx +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -31,7 +31,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { CoachSection } from "../coach-section"; function render(locale: "en" | "de" = "en") { diff --git a/src/components/settings/__tests__/export-section.test.tsx b/src/components/settings/__tests__/export-section.test.tsx index bc648c7d2..fc42f9c5a 100644 --- a/src/components/settings/__tests__/export-section.test.tsx +++ b/src/components/settings/__tests__/export-section.test.tsx @@ -44,7 +44,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { ExportSection } from "../export-section"; function render(node: React.ReactElement, locale: "en" | "de" = "en") { diff --git a/src/components/settings/__tests__/gesundheitsakte-section.test.tsx b/src/components/settings/__tests__/gesundheitsakte-section.test.tsx index baee6ffba..a66fd760a 100644 --- a/src/components/settings/__tests__/gesundheitsakte-section.test.tsx +++ b/src/components/settings/__tests__/gesundheitsakte-section.test.tsx @@ -29,7 +29,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { GesundheitsakteSection } from "../gesundheitsakte-section"; function render(node: React.ReactElement, locale: "en" | "de" = "en") { diff --git a/src/components/settings/__tests__/medications-section.test.tsx b/src/components/settings/__tests__/medications-section.test.tsx index 7bc97a732..f44a9f36d 100644 --- a/src/components/settings/__tests__/medications-section.test.tsx +++ b/src/components/settings/__tests__/medications-section.test.tsx @@ -65,7 +65,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { MedicationsSection } from "../medications-section"; function render(locale: "en" | "de" = "en"): string { diff --git a/src/components/settings/__tests__/mood-section.test.tsx b/src/components/settings/__tests__/mood-section.test.tsx index 5f949344b..894b02cc4 100644 --- a/src/components/settings/__tests__/mood-section.test.tsx +++ b/src/components/settings/__tests__/mood-section.test.tsx @@ -131,7 +131,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { MoodSection } from "../mood-section"; function render(locale: "en" | "de" = "en"): string { diff --git a/src/components/settings/__tests__/section-frame-harness.tsx b/src/components/settings/__tests__/section-frame-harness.tsx new file mode 100644 index 000000000..33e0fd68e --- /dev/null +++ b/src/components/settings/__tests__/section-frame-harness.tsx @@ -0,0 +1,45 @@ +"use client"; + +/** + * Test-only harness that reproduces the page heading the live `SettingsShell` + * paints for a given section slug (`settings-shell.tsx` headingBlock). The + * per-section SSR smoke tests render a section body through this harness so + * they can assert the visible heading + its historic `settings-section--title` + * id alongside the body — mirroring what a user sees on the route. + * + * Production no longer wraps section bodies in a standalone frame component: + * the shell paints the heading from the slug and each section body is just its + * cards. This harness lives in `__tests__` only and is never bundled. + */ + +import * as React from "react"; + +import { useTranslations } from "@/lib/i18n/context"; +import type { SettingsSectionSlug } from "../section-slugs"; + +export function SettingsSectionFrame({ + slug, + children, +}: { + slug: SettingsSectionSlug; + children: React.ReactNode; +}) { + const { t } = useTranslations(); + const titleId = `settings-section-${slug}-title`; + + return ( +
      +
      +
      +

      + {t(`settings.sections.${slug}.title`)} +

      +

      + {t(`settings.sections.${slug}.subtitle`)} +

      +
      +
      + {children} +
      + ); +} diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 296ce813a..5744b4ef7 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -87,7 +87,7 @@ vi.mock("@/components/settings/thresholds-editor-section", () => ({ ThresholdsEditorSection: () =>
      , })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import type { SettingsSectionSlug } from "../section-slugs"; import { AboutSection } from "../about-section"; import { AccountSection } from "../account-section"; diff --git a/src/components/settings/settings-section-frame.tsx b/src/components/settings/settings-section-frame.tsx deleted file mode 100644 index 98b9eee63..000000000 --- a/src/components/settings/settings-section-frame.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; - -/** - * `` — the canonical page-frame for every Settings - * section. The settings twin of the admin renderer's `SectionFrame` - * (`src/app/admin/[section]/renderer.tsx`): it emits the one visible - * heading + subheading every page must show, in the reference style used by - * the Admin Konsole → Übersicht landing. - * - * Before this primitive every settings section hand-rolled its own - * `sr-only` `

      `, so no settings page showed a visible heading and the - * left-menu-to-first-card alignment drifted page to page. Routing all 19 - * sections through one frame fixes both: a constant-height heading block - * above the first card, and a real visible `h1 + subtitle`. - * - * - * {cards} - * - * - * Title comes from `settings.sections..title`; subtitle from - * `settings.sections..subtitle`. The `

      ` keeps the historic - * `settings-section--title` id so the section's own `aria-labelledby` - * linkage — and the spotlight-tour anchors that reference it — stay intact. - * - * `topSlot` renders ABOVE the heading (used by the Layout-hub child - * editors for their "← back to hub" link, which must sit above the title). - * `headingAccessory` renders inline at the top-right of the heading row - * (used by Gesundheitsakte for its guided-tour re-entry trigger). - */ - -import * as React from "react"; - -import { useTranslations } from "@/lib/i18n/context"; -import type { SettingsSectionSlug } from "./section-slugs"; - -interface SettingsSectionFrameBaseProps { - /** Optional content rendered above the heading (e.g. a back-to-hub link). */ - topSlot?: React.ReactNode; - /** Optional content rendered inline at the top-right of the heading row - * (e.g. a guided-tour re-entry trigger). */ - headingAccessory?: React.ReactNode; - children: React.ReactNode; -} - -/** - * Slug mode (the 19 shell sections): title + subtitle come from - * `settings.sections..{title,subtitle}` and the `

      ` keeps the - * historic `settings-section--title` id for `aria-labelledby` and - * spotlight-tour anchors. - */ -interface SettingsSectionFrameSlugProps extends SettingsSectionFrameBaseProps { - slug: SettingsSectionSlug; -} - -/** - * Explicit mode (the per-module settings pages — Vorsorge / Illness / Labs — - * which deliberately stay out of the slug registry because their static - * routes win over `/settings/[section]`): caller passes resolved strings and - * an optional heading id. This lets `ModuleSettingsFrame` delegate here so - * every settings page shares one heading frame. - */ -interface SettingsSectionFrameExplicitProps extends SettingsSectionFrameBaseProps { - title: string; - subtitle: string; - /** Heading element id (defaults to a stable slug-less id). */ - titleId?: string; -} - -export type SettingsSectionFrameProps = - | SettingsSectionFrameSlugProps - | SettingsSectionFrameExplicitProps; - -function isSlugProps( - props: SettingsSectionFrameProps, -): props is SettingsSectionFrameSlugProps { - return "slug" in props; -} - -export function SettingsSectionFrame(props: SettingsSectionFrameProps) { - const { t } = useTranslations(); - const { topSlot, headingAccessory, children } = props; - - let title: string; - let subtitle: string; - let titleId: string; - if (isSlugProps(props)) { - titleId = `settings-section-${props.slug}-title`; - title = t(`settings.sections.${props.slug}.title`); - subtitle = t(`settings.sections.${props.slug}.subtitle`); - } else { - titleId = props.titleId ?? "settings-section-title"; - title = props.title; - subtitle = props.subtitle; - } - - return ( -
      - {topSlot ?
      {topSlot}
      : null} -
      -
      -

      - {title} -

      -

      {subtitle}

      -
      - {headingAccessory ? ( -
      {headingAccessory}
      - ) : null} -
      - {children} -
      - ); -} From 774bc37382bdbd1f6f363018ab0cf74b74b8e917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 12:46:30 +0200 Subject: [PATCH 3/3] refactor(settings): route three stray cards through SettingsCard The Layout hub links, the global injection-site exclusion card, and the mobile passkey list hand-rolled their own card surfaces with rounded-lg or sm:p-5 padding, drifting from the SettingsCard contract (rounded-xl, p-4 md:p-6). Wrap them in SettingsCard so every settings surface paints the same radius, border, and padding. Content and behaviour are unchanged. --- .../account-section/passkey-list-section.tsx | 8 +++----- src/components/settings/injection-sites-card.tsx | 5 +++-- src/components/settings/layout-section.tsx | 15 +++++++-------- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/components/settings/account-section/passkey-list-section.tsx b/src/components/settings/account-section/passkey-list-section.tsx index 4144f1ef9..c28c5a274 100644 --- a/src/components/settings/account-section/passkey-list-section.tsx +++ b/src/components/settings/account-section/passkey-list-section.tsx @@ -15,6 +15,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; +import { SettingsCard } from "@/components/settings/settings-card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { formatDate } from "@/lib/format"; @@ -198,10 +199,7 @@ export function PasskeyListSection({ data-testid="passkeys-mobile-list" > {passkeys.map((pk) => ( -
    • +

      {pk.name}

      @@ -267,7 +265,7 @@ export function PasskeyListSection({
      -
    • + ))}

    {deleteMsg && ( diff --git a/src/components/settings/injection-sites-card.tsx b/src/components/settings/injection-sites-card.tsx index 3c1faf124..78cfb3a24 100644 --- a/src/components/settings/injection-sites-card.tsx +++ b/src/components/settings/injection-sites-card.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Syringe } from "lucide-react"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Checkbox } from "@/components/ui/checkbox"; import { useTranslations } from "@/lib/i18n/context"; @@ -66,7 +67,7 @@ export function InjectionSitesCard({ } return ( -
    + ))}
    -
    + ); } diff --git a/src/components/settings/layout-section.tsx b/src/components/settings/layout-section.tsx index e5ad4528c..7605fcdc4 100644 --- a/src/components/settings/layout-section.tsx +++ b/src/components/settings/layout-section.tsx @@ -8,8 +8,8 @@ import { type LucideIcon, } from "lucide-react"; -import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; +import { SettingsCard } from "./settings-card"; /** * v1.17.1 (F-2) — the one "Layout & Personalization" home. @@ -53,19 +53,18 @@ const LAYOUT_LINKS: ReadonlyArray = [ export function LayoutSection() { const { t } = useTranslations(); - // v1.18.6 (W9) — the visible heading + subtitle now come from the shared - // `` in the route; this body is the hub link list. + // The visible heading + subtitle are painted by `SettingsShell` from the + // section slug; this body is the hub link list. return (
      {LAYOUT_LINKS.map((link) => { const Icon = link.icon; return (
    • -
    • ); })}